diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f01e5b5f4..278a4b739 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,12 +1,19 @@ { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "generate-api", - "path": "frontend", - "problemMatcher": [], - "label": "Generate frontend code from OpenAPI" - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "generate:api", + "path": "frontend", + "problemMatcher": [], + "label": "Generate frontend code from OpenAPI" + }, + { + "type": "npm", + "script": "generate:module-states-map", + "path": "frontend", + "problemMatcher": [], + "label": "Generate states-map file for modules" + } + ] } diff --git a/backend_py/primary/poetry.lock b/backend_py/primary/poetry.lock index 11c7faff6..d974a09b1 100644 --- a/backend_py/primary/poetry.lock +++ b/backend_py/primary/poetry.lock @@ -1,4 +1,173 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.13.1" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2349a6b642020bf20116a8a5c83bae8ba071acf1461c7cbe45fc7fafd552e7e2"}, + {file = "aiohttp-3.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a8434ca31c093a90edb94d7d70e98706ce4d912d7f7a39f56e1af26287f4bb7"}, + {file = "aiohttp-3.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bd610a7e87431741021a9a6ab775e769ea8c01bf01766d481282bfb17df597f"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:777ec887264b629395b528af59b8523bf3164d4c6738cd8989485ff3eda002e2"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac1892f56e2c445aca5ba28f3bf8e16b26dfc05f3c969867b7ef553b74cb4ebe"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499a047d1c5e490c31d16c033e2e47d1358f0e15175c7a1329afc6dfeb04bc09"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:610be925f89501938c770f1e28ca9dd62e9b308592c81bd5d223ce92434c0089"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90eb902c06c6ac85d6b80fa9f2bd681f25b1ebf73433d428b3d182a507242711"}, + {file = "aiohttp-3.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab8ac3224b2beb46266c094b3869d68d5f96f35dba98e03dea0acbd055eefa03"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:79ac65b6e2731558aad1e4c1a655d2aa2a77845b62acecf5898b0d4fe8c76618"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dadbd858ed8c04d1aa7a2a91ad65f8e1fbd253ae762ef5be8111e763d576c3c"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e0b2ccd331bc77149e88e919aa95c228a011e03e1168fd938e6aeb1a317d7a8a"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fba3c85fb24fe204e73f3c92f09f4f5cfa55fa7e54b34d59d91b7c5a258d0f6a"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8d5011e4e741d2635cda18f2997a56e8e1d1b94591dc8732f2ef1d3e1bfc5f45"}, + {file = "aiohttp-3.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c5fe2728a89c82574bd3132d59237c3b5fb83e2e00a320e928d05d74d1ae895f"}, + {file = "aiohttp-3.13.1-cp310-cp310-win32.whl", hash = "sha256:add14a5e68cbcfc526c89c1ed8ea963f5ff8b9b4b854985b07820c6fbfdb3c3c"}, + {file = "aiohttp-3.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:a4cc9d9cfdf75a69ae921c407e02d0c1799ab333b0bc6f7928c175f47c080d6a"}, + {file = "aiohttp-3.13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eefa0a891e85dca56e2d00760945a6325bd76341ec386d3ad4ff72eb97b7e64"}, + {file = "aiohttp-3.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c20eb646371a5a57a97de67e52aac6c47badb1564e719b3601bbb557a2e8fd0"}, + {file = "aiohttp-3.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfc28038cd86fb1deed5cc75c8fda45c6b0f5c51dfd76f8c63d3d22dc1ab3d1b"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b22eeffca2e522451990c31a36fe0e71079e6112159f39a4391f1c1e259a795"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:65782b2977c05ebd78787e3c834abe499313bf69d6b8be4ff9c340901ee7541f"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dacba54f9be3702eb866b0b9966754b475e1e39996e29e442c3cd7f1117b43a9"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa878da718e8235302c365e376b768035add36b55177706d784a122cb822a6a4"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e4b4e607fbd4964d65945a7b9d1e7f98b0d5545736ea613f77d5a2a37ff1e46"}, + {file = "aiohttp-3.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c3db2d0e5477ad561bf7ba978c3ae5f8f78afda70daa05020179f759578754f"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9739d34506fdf59bf2c092560d502aa728b8cdb33f34ba15fb5e2852c35dd829"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b902e30a268a85d50197b4997edc6e78842c14c0703450f632c2d82f17577845"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbfc04c8de7def6504cce0a97f9885a5c805fd2395a0634bc10f9d6ecb42524"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6941853405a38a5eeb7d9776db77698df373ff7fa8c765cb81ea14a344fccbeb"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7764adcd2dc8bd21c8228a53dda2005428498dc4d165f41b6086f0ac1c65b1c9"}, + {file = "aiohttp-3.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c09e08d38586fa59e5a2f9626505a0326fadb8e9c45550f029feeb92097a0afc"}, + {file = "aiohttp-3.13.1-cp311-cp311-win32.whl", hash = "sha256:ce1371675e74f6cf271d0b5530defb44cce713fd0ab733713562b3a2b870815c"}, + {file = "aiohttp-3.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:77a2f5cc28cf4704cc157be135c6a6cfb38c9dea478004f1c0fd7449cf445c28"}, + {file = "aiohttp-3.13.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0760bd9a28efe188d77b7c3fe666e6ef74320d0f5b105f2e931c7a7e884c8230"}, + {file = "aiohttp-3.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7129a424b441c3fe018a414401bf1b9e1d49492445f5676a3aecf4f74f67fcdb"}, + {file = "aiohttp-3.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e1cb04ae64a594f6ddf5cbb024aba6b4773895ab6ecbc579d60414f8115e9e26"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:782d656a641e755decd6bd98d61d2a8ea062fd45fd3ff8d4173605dd0d2b56a1"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f92ad8169767429a6d2237331726c03ccc5f245222f9373aa045510976af2b35"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e778f634ca50ec005eefa2253856921c429581422d887be050f2c1c92e5ce12"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bc36b41cf4aab5d3b34d22934a696ab83516603d1bc1f3e4ff9930fe7d245e5"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3fd4570ea696aee27204dd524f287127ed0966d14d309dc8cc440f474e3e7dbd"}, + {file = "aiohttp-3.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7bda795f08b8a620836ebfb0926f7973972a4bf8c74fdf9145e489f88c416811"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:055a51d90e351aae53dcf324d0eafb2abe5b576d3ea1ec03827d920cf81a1c15"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d4131df864cbcc09bb16d3612a682af0db52f10736e71312574d90f16406a867"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:163d3226e043f79bf47c87f8dfc89c496cc7bc9128cb7055ce026e435d551720"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a2370986a3b75c1a5f3d6f6d763fc6be4b430226577b0ed16a7c13a75bf43d8f"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d7c14de0c7c9f1e6e785ce6cbe0ed817282c2af0012e674f45b4e58c6d4ea030"}, + {file = "aiohttp-3.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb611489cf0db10b99beeb7280bd39e0ef72bc3eb6d8c0f0a16d8a56075d1eb7"}, + {file = "aiohttp-3.13.1-cp312-cp312-win32.whl", hash = "sha256:f90fe0ee75590f7428f7c8b5479389d985d83c949ea10f662ab928a5ed5cf5e6"}, + {file = "aiohttp-3.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:3461919a9dca272c183055f2aab8e6af0adc810a1b386cce28da11eb00c859d9"}, + {file = "aiohttp-3.13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55785a7f8f13df0c9ca30b5243d9909bd59f48b274262a8fe78cee0828306e5d"}, + {file = "aiohttp-3.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bef5b83296cebb8167707b4f8d06c1805db0af632f7a72d7c5288a84667e7c3"}, + {file = "aiohttp-3.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27af0619c33f9ca52f06069ec05de1a357033449ab101836f431768ecfa63ff5"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a47fe43229a8efd3764ef7728a5c1158f31cdf2a12151fe99fde81c9ac87019c"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e68e126de5b46e8b2bee73cab086b5d791e7dc192056916077aa1e2e2b04437"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e65ef49dd22514329c55970d39079618a8abf856bae7147913bb774a3ab3c02f"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e425a7e0511648b3376839dcc9190098671a47f21a36e815b97762eb7d556b0"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:010dc9b7110f055006acd3648d5d5955bb6473b37c3663ec42a1b4cba7413e6b"}, + {file = "aiohttp-3.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b5c722d0ca5f57d61066b5dfa96cdb87111e2519156b35c1f8dd17c703bee7a"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:93029f0e9b77b714904a281b5aa578cdc8aa8ba018d78c04e51e1c3d8471b8ec"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d1824c7d08d8ddfc8cb10c847f696942e5aadbd16fd974dfde8bd2c3c08a9fa1"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8f47d0ff5b3eb9c1278a2f56ea48fda667da8ebf28bd2cb378b7c453936ce003"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a396b1da9b51ded79806ac3b57a598f84e0769eaa1ba300655d8b5e17b70c7b"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d9c52a65f54796e066b5d674e33b53178014752d28bca555c479c2c25ffcec5b"}, + {file = "aiohttp-3.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a89da72d18d6c95a653470b78d8ee5aa3c4b37212004c103403d0776cbea6ff0"}, + {file = "aiohttp-3.13.1-cp313-cp313-win32.whl", hash = "sha256:02e0258b7585ddf5d01c79c716ddd674386bfbf3041fbbfe7bdf9c7c32eb4a9b"}, + {file = "aiohttp-3.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:ef56ffe60e8d97baac123272bde1ab889ee07d3419606fae823c80c2b86c403e"}, + {file = "aiohttp-3.13.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:77f83b3dc5870a2ea79a0fcfdcc3fc398187ec1675ff61ec2ceccad27ecbd303"}, + {file = "aiohttp-3.13.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9cafd2609ebb755e47323306c7666283fbba6cf82b5f19982ea627db907df23a"}, + {file = "aiohttp-3.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9c489309a2ca548d5f11131cfb4092f61d67954f930bba7e413bcdbbb82d7fae"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79ac15fe5fdbf3c186aa74b656cd436d9a1e492ba036db8901c75717055a5b1c"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:095414be94fce3bc080684b4cd50fb70d439bc4662b2a1984f45f3bf9ede08aa"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c68172e1a2dca65fa1272c85ca72e802d78b67812b22827df01017a15c5089fa"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3751f9212bcd119944d4ea9de6a3f0fee288c177b8ca55442a2cdff0c8201eb3"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8619dca57d98a8353abdc7a1eeb415548952b39d6676def70d9ce76d41a046a9"}, + {file = "aiohttp-3.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97795a0cb0a5f8a843759620e9cbd8889f8079551f5dcf1ccd99ed2f056d9632"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1060e058da8f9f28a7026cdfca9fc886e45e551a658f6a5c631188f72a3736d2"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f48a2c26333659101ef214907d29a76fe22ad7e912aa1e40aeffdff5e8180977"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1dfad638b9c91ff225162b2824db0e99ae2d1abe0dc7272b5919701f0a1e685"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8fa09ab6dd567cb105db4e8ac4d60f377a7a94f67cf669cac79982f626360f32"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4159fae827f9b5f655538a4f99b7cbc3a2187e5ca2eee82f876ef1da802ccfa9"}, + {file = "aiohttp-3.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ad671118c19e9cfafe81a7a05c294449fe0ebb0d0c6d5bb445cd2190023f5cef"}, + {file = "aiohttp-3.13.1-cp314-cp314-win32.whl", hash = "sha256:c5c970c148c48cf6acb65224ca3c87a47f74436362dde75c27bc44155ccf7dfc"}, + {file = "aiohttp-3.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:748a00167b7a88385756fa615417d24081cba7e58c8727d2e28817068b97c18c"}, + {file = "aiohttp-3.13.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:390b73e99d7a1f0f658b3f626ba345b76382f3edc65f49d6385e326e777ed00e"}, + {file = "aiohttp-3.13.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e83abb330e687e019173d8fc1fd6a1cf471769624cf89b1bb49131198a810a"}, + {file = "aiohttp-3.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b20eed07131adbf3e873e009c2869b16a579b236e9d4b2f211bf174d8bef44a"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58fee9ef8477fd69e823b92cfd1f590ee388521b5ff8f97f3497e62ee0656212"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1f62608fcb7b3d034d5e9496bea52d94064b7b62b06edba82cd38191336bbeda"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdc4d81c3dfc999437f23e36d197e8b557a3f779625cd13efe563a9cfc2ce712"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:601d7ec812f746fd80ff8af38eeb3f196e1bab4a4d39816ccbc94c222d23f1d0"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47c3f21c469b840d9609089435c0d9918ae89f41289bf7cc4afe5ff7af5458db"}, + {file = "aiohttp-3.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6c6cdc0750db88520332d4aaa352221732b0cafe89fd0e42feec7cb1b5dc236"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:58a12299eeb1fca2414ee2bc345ac69b0f765c20b82c3ab2a75d91310d95a9f6"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0989cbfc195a4de1bb48f08454ef1cb47424b937e53ed069d08404b9d3c7aea1"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:feb5ee664300e2435e0d1bc3443a98925013dfaf2cae9699c1f3606b88544898"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:58a6f8702da0c3606fb5cf2e669cce0ca681d072fe830968673bb4c69eb89e88"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a417ceb433b9d280e2368ffea22d4bc6e3e0d894c4bc7768915124d57d0964b6"}, + {file = "aiohttp-3.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ac8854f7b0466c5d6a9ea49249b3f6176013859ac8f4bb2522ad8ed6b94ded2"}, + {file = "aiohttp-3.13.1-cp314-cp314t-win32.whl", hash = "sha256:be697a5aeff42179ed13b332a411e674994bcd406c81642d014ace90bf4bb968"}, + {file = "aiohttp-3.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1d6aa90546a4e8f20c3500cb68ab14679cd91f927fa52970035fd3207dfb3da"}, + {file = "aiohttp-3.13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a5dc5c3b086adc232fd07e691dcc452e8e407bf7c810e6f7e18fd3941a24c5c0"}, + {file = "aiohttp-3.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fb7c5f0b35f5a3a06bd5e1a7b46204c2dca734cd839da830db81f56ce60981fe"}, + {file = "aiohttp-3.13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb1e557bd1a90f28dc88a6e31332753795cd471f8d18da749c35930e53d11880"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e95ea8fb27fbf667d322626a12db708be308b66cd9afd4a997230ded66ffcab4"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f37da298a486e53f9b5e8ef522719b3787c4fe852639a1edcfcc9f981f2c20ba"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:37cc1b9773d2a01c3f221c3ebecf0c82b1c93f55f3fde52929e40cf2ed777e6c"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:412bfc63a6de4907aae6041da256d183f875bf4dc01e05412b1d19cfc25ee08c"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ccd2946aadf7793643b57d98d5a82598295a37f98d218984039d5179823cd5"}, + {file = "aiohttp-3.13.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:51b3c44434a50bca1763792c6b98b9ba1d614339284780b43107ef37ec3aa1dc"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9bff813424c70ad38667edfad4fefe8ca1b09a53621ce7d0fd017e418438f58a"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed782a438ff4b66ce29503a1555be51a36e4b5048c3b524929378aa7450c26a9"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d6fd6e9e3578a7aeb0fa11e9a544dceccb840330277bf281325aa0fe37787e"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c5e2660c6d6ab0d85c45bc8bd9f685983ebc63a5c7c0fd3ddeb647712722eca"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:168279a11571a39d689fc7b9725ddcde0dc68f2336b06b69fcea0203f9fb25d8"}, + {file = "aiohttp-3.13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ff0357fa3dd28cf49ad8c515452a1d1d7ad611b513e0a4f6fa6ad6780abaddfd"}, + {file = "aiohttp-3.13.1-cp39-cp39-win32.whl", hash = "sha256:a617769e8294ca58601a579697eae0b0e1b1ef770c5920d55692827d6b330ff9"}, + {file = "aiohttp-3.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:f2543eebf890739fd93d06e2c16d97bdf1301d2cda5ffceb7a68441c7b590a92"}, + {file = "aiohttp-3.13.1.tar.gz", hash = "sha256:4b7ee9c355015813a6aa085170b96ec22315dabc3d866fd77d147927000e9464"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" [[package]] name = "annotated-types" @@ -58,6 +227,18 @@ files = [ {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, ] +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + [[package]] name = "azure-core" version = "1.35.0" @@ -95,6 +276,22 @@ files = [ azure-core = ">=1.24.0" opentelemetry-api = ">=1.12.0" +[[package]] +name = "azure-cosmos" +version = "4.14.0" +description = "Microsoft Azure Cosmos Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_cosmos-4.14.0-py3-none-any.whl", hash = "sha256:9d659e9be3d13b95c639f7fbae6b159cb62025d16aa17e1a4171077986c28a58"}, + {file = "azure_cosmos-4.14.0.tar.gz", hash = "sha256:3cc7ca6a68b87e4da18f9e9b07a4a9bb03ddf015b4ed1f48f7fe140e6d6689b0"}, +] + +[package.dependencies] +azure-core = ">=1.30.0" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-identity" version = "1.24.0" @@ -953,6 +1150,146 @@ type1 = ["xattr ; sys_platform == \"darwin\""] unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -1713,6 +2050,162 @@ requests-oauthlib = ">=0.5.0" [package.extras] async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] +[[package]] +name = "multidict" +version = "6.7.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, + {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, + {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, + {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, + {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, + {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, + {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, + {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, + {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, + {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, + {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, + {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, + {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, + {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, + {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, + {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, + {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, + {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, + {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, + {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, + {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, + {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"}, + {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"}, + {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"}, + {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"}, + {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, + {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, +] + [[package]] name = "mypy" version = "1.17.1" @@ -1785,6 +2278,18 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] + [[package]] name = "ndindex" version = "1.10.0" @@ -2787,6 +3292,138 @@ mmh3 = "*" redis = ">=4.2.0rc1" typing_extensions = "*" +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + [[package]] name = "psutil" version = "7.0.0" @@ -4079,6 +4716,151 @@ xtgeo = "*" docs = ["autoapi", "myst-parser", "sphinx", "sphinx-argparse", "sphinx-autodoc-typehints (<2.4)", "sphinx-copybutton", "sphinx-togglebutton", "sphinx_rtd_theme", "sphinxcontrib-apidoc"] tests = ["coverage (>=4.1)", "mypy", "pylint", "pytest", "pytest-cov", "pytest-mock", "pytest-runner", "pytest-xdist", "rstcheck", "ruff", "types-PyYAML"] +[[package]] +name = "yarl" +version = "1.22.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + [[package]] name = "zipp" version = "3.23.0" @@ -4102,4 +4884,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "dd133211ff1a0bf543acd55baa620f049adca8bcc7685844514e3049c1ac483e" +content-hash = "b1f650c3f338e745a17024cb527d4aaccbfe4dc66edd6bacc51439fe244d81d5" diff --git a/backend_py/primary/primary/config.py b/backend_py/primary/primary/config.py index 14c3d9df8..ba0c429fa 100644 --- a/backend_py/primary/primary/config.py +++ b/backend_py/primary/primary/config.py @@ -33,3 +33,7 @@ DEFAULT_STALE_WHILE_REVALIDATE = 3600 * 24 # 24 hour REDIS_USER_SESSION_URL = "redis://redis-user-session:6379" REDIS_CACHE_URL = "redis://redis-cache:6379" + +COSMOS_DB_PROD_CONNECTION_STRING = os.environ.get("WEBVIZ_DB_CONNECTION_STRING", None) +COSMOS_DB_EMULATOR_URI = "https://cosmos-db-emulator:8081" +COSMOS_DB_EMULATOR_KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index 888b514ba..bf56fc103 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -11,6 +11,7 @@ from starsessions.stores.redis import RedisStore from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware +from primary.persistence.setup_local_database import maybe_setup_local_database from primary.auth.auth_helper import AuthHelper from primary.auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware from primary.middleware.add_process_time_to_server_timing_middleware import AddProcessTimeToServerTimingMiddleware @@ -34,6 +35,7 @@ from primary.routers.vfp.router import router as vfp_router from primary.routers.well.router import router as well_router from primary.routers.well_completions.router import router as well_completions_router +from primary.routers.persistence.router import router as persistence_router from primary.services.sumo_access.sumo_fingerprinter import SumoFingerprinterFactory from primary.services.utils.httpx_async_client_wrapper import HTTPX_ASYNC_CLIENT_WRAPPER from primary.services.utils.task_meta_tracker import TaskMetaTrackerFactory @@ -57,12 +59,16 @@ logging.getLogger("primary.routers.grid3d").setLevel(logging.DEBUG) logging.getLogger("primary.routers.dev").setLevel(logging.DEBUG) logging.getLogger("primary.routers.surface").setLevel(logging.DEBUG) +logging.getLogger("primary.persistence").setLevel(logging.DEBUG) # logging.getLogger("primary.auth").setLevel(logging.DEBUG) # logging.getLogger("uvicorn.error").setLevel(logging.DEBUG) # logging.getLogger("uvicorn.access").setLevel(logging.DEBUG) LOGGER = logging.getLogger(__name__) +# Setup Cosmos DB emulator database if running locally +maybe_setup_local_database() + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.name}" @@ -115,6 +121,7 @@ async def lifespan_handler_async(_fastapi_app: FastAPI) -> AsyncIterator[None]: app.include_router(rft_router, prefix="/rft", tags=["rft"]) app.include_router(vfp_router, prefix="/vfp", tags=["vfp"]) app.include_router(dev_router, prefix="/dev", tags=["dev"], include_in_schema=False) +app.include_router(persistence_router, prefix="/persistence", tags=["persistence"]) auth_helper = AuthHelper() app.include_router(auth_helper.router) diff --git a/backend_py/primary/primary/persistence/__init__.py b/backend_py/primary/primary/persistence/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/persistence/_utils.py b/backend_py/primary/primary/persistence/_utils.py new file mode 100644 index 000000000..c2a85fc9e --- /dev/null +++ b/backend_py/primary/primary/persistence/_utils.py @@ -0,0 +1,38 @@ +import hashlib +from typing import Any, TypeVar, cast + +from azure.core.async_paging import AsyncPageIterator, AsyncItemPaged + + +T = TypeVar("T") + + +# Utility function to hash a JSON string using SHA-256 +# This function mimics the behavior of TextEncoder in JavaScript, which encodes strings to +# UTF-8 before hashing. The output is a hexadecimal string representation of the hash. +# +# It is important that this function returns the same hash as the JavaScript version +def hash_session_content_string(string: str) -> str: + data = string.encode("utf-8") # Matches TextEncoder behavior + hash_bytes = hashlib.sha256(data).digest() + hash_hex = "".join(f"{b:02x}" for b in hash_bytes) + return hash_hex + + +def cast_query_params(params: list[dict[str, Any]]) -> list[dict[str, object]]: + return cast(list[dict[str, object]], params) + + +def query_by_page(query_iterable: AsyncItemPaged[T], page_token: str | None) -> AsyncPageIterator[T]: + """ + Cosmosdb's `by_page` returns a more narrow subtype than anticipated. This makes + extra's like `.continuation_token` not show up in returned value's type. + + This util function correctly casts the return value to the expected type + """ + pager = query_iterable.by_page(page_token) + + if not isinstance(pager, AsyncPageIterator): + raise TypeError("Expected AsyncPageIterator from query_items_by_page_token_async") + + return cast(AsyncPageIterator[T], pager) diff --git a/backend_py/primary/primary/persistence/cosmosdb/__init__.py b/backend_py/primary/primary/persistence/cosmosdb/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/persistence/cosmosdb/cosmos_container.py b/backend_py/primary/primary/persistence/cosmosdb/cosmos_container.py new file mode 100644 index 000000000..023d0ca8e --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/cosmos_container.py @@ -0,0 +1,241 @@ +import logging +from types import TracebackType +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar +from azure.cosmos.aio import ContainerProxy +from azure.cosmos import exceptions +from pydantic import BaseModel, ValidationError + +from primary.persistence._utils import query_by_page + +from .cosmos_database import CosmosDatabase +from .exceptions import ( + DatabaseAccessError, + DatabaseAccessIntegrityError, + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + + +logger = logging.getLogger(__name__) + +T = TypeVar("T", bound=BaseModel) + + +class CosmosContainer(Generic[T]): + """ + CosmosContainer provides access to a specific container in a Cosmos DB database. + It allows for querying, inserting, updating, and deleting items in the container. + It uses a Pydantic model for item validation and serialization. + + It is designed to be used with asynchronous context management, ensuring proper resource cleanup. + """ + + def __init__( + self, + database_name: str, + container_name: str, + database: CosmosDatabase, + container: ContainerProxy, + validation_model: Type[T], + ): + self._database_name = database_name + self._container_name = container_name + self._database = database + self._container = container + self._validation_model: Type[T] = validation_model + + @classmethod + def create_instance( + cls, database_name: str, container_name: str, validation_model: Type[T] + ) -> "CosmosContainer[T]": + """Create a CosmosContainer instance.""" + database = CosmosDatabase.create_instance(database_name) + container = database.get_container(container_name) + logger.debug("[CosmosContainer] Created for container '%s' in database '%s'", container_name, database_name) + return cls(database_name, container_name, database, container, validation_model) + + async def __aenter__(self) -> "CosmosContainer[T]": + return self + + async def __aexit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: + await self.close_async() + + def _make_exception(self, op: str, exc: exceptions.CosmosHttpResponseError) -> DatabaseAccessError: + """Map Cosmos error to a data-access exception with rich context and re-raise.""" + headers = getattr(exc, "headers", {}) or {} + status = getattr(exc, "status_code", None) + # Cosmos uses x-ms-substatus for more detail (e.g., 1002) + substatus_raw = headers.get("x-ms-substatus") + try: + substatus = int(substatus_raw) if substatus_raw is not None else None + except ValueError: + substatus = None + activity_id = headers.get("x-ms-activity-id") + + msg = ( + f"[{op}] Cosmos error on {self._database_name}/{self._container_name}: " + f"{getattr(exc, 'message', None) or str(exc)} " + f"(status={status}, substatus={substatus}, activity_id={activity_id})" + ) + + # Log with stack trace + logger.exception( + "[CosmosContainer] %s", + msg, + extra={ + "database": self._database_name, + "container": self._container_name, + "operation": op, + "status_code": status, + "sub_status": substatus, + "activity_id": activity_id, + }, + ) + + if status == 404: + return DatabaseAccessNotFoundError(msg, status_code=status, sub_status=substatus, activity_id=activity_id) + if status == 409: + return DatabaseAccessConflictError(msg, status_code=status, sub_status=substatus, activity_id=activity_id) + if status == 412: + return DatabaseAccessPreconditionFailedError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) + if status in (401, 403): + return DatabaseAccessPermissionError(msg, status_code=status, sub_status=substatus, activity_id=activity_id) + if status in (429, 503): + # Typically retryable + return DatabaseAccessThrottledError(msg, status_code=status, sub_status=substatus, activity_id=activity_id) + + # Fallback + return DatabaseAccessTransportError(msg, status_code=status, sub_status=substatus, activity_id=activity_id) + + async def query_items_async(self, query: str, parameters: Optional[List[Dict[str, object]]] = None) -> List[T]: + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + items = [item async for item in items_iterable] + return [self._validation_model.model_validate(item) for item in items] + except ValidationError as validation_error: + logger.error("[CosmosContainer] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("query_items_async", error) + + async def query_items_by_page_token_async( + self, + query: str, + page_token: str | None, + parameters: Optional[List[Dict[str, object]]] = None, + page_size: Optional[int] = None, + ) -> tuple[list[T], str | None]: + query_iterable = self._container.query_items(query=query, parameters=parameters, max_item_count=page_size) + + pager = query_by_page(query_iterable, page_token) + + try: + page = await anext(pager) + except StopAsyncIteration: + # No items found - return empty list and no continuation token + return ([], None) + + token = pager.continuation_token + + items = [self._validation_model.model_validate(item) async for item in page] + + return (items, token) + + async def get_item_async(self, item_id: str, partition_key: str) -> T: + try: + item = await self._container.read_item(item=item_id, partition_key=partition_key) + return self._validation_model.model_validate(item) + except ValidationError as validation_error: + logger.error("[CosmosContainer] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("get_item_async", error) from error + + async def insert_item_async(self, item: T) -> str: + try: + body: Dict[str, Any] = self._validation_model.model_validate(item).model_dump(by_alias=True, mode="json") + result = await self._container.create_item(body) + return result["id"] + except ValidationError as validation_error: + logger.error("[CosmosContainer] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("insert_item_async", error) from error + + async def delete_item_async(self, item_id: str, partition_key: str) -> None: + try: + await self._container.delete_item(item=item_id, partition_key=partition_key) + logger.debug("[CosmosContainer] Deleted item '%s' from '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("delete_item_async", error) from error + + async def update_item_async(self, item_id: str, updated_item: T) -> None: + try: + validated = self._validation_model.model_validate(updated_item).model_dump(by_alias=True, mode="json") + + if validated.get("id") and validated["id"] != item_id: + raise DatabaseAccessIntegrityError(f"id mismatch: payload id {validated['id']} != path id {item_id}") + + await self._container.replace_item(item=item_id, body=validated) + + logger.debug("[CosmosContainer] Updated item '%s' in '%s'", item_id, self._container_name) + except ValidationError as validation_error: + logger.error("[CosmosContainer] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("update_item_async", error) from error + + async def patch_item_async( + self, + item_id: str, + partition_key: str, + patch_operations: list[dict], + *, + filter_predicate: str | None = None, + ) -> None: + try: + await self._container.patch_item( + item=item_id, + partition_key=partition_key, + patch_operations=patch_operations, + filter_predicate=filter_predicate, + no_response=True, + ) + logger.debug("[CosmosContainer] Patched item '%s' in '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("patch_item_async", error) from error + + async def query_projection_async( + self, + query: str, + parameters: Optional[List[dict]] = None, + ) -> List[Dict[str, Any]]: + """ + Run a query that returns raw dicts (no Pydantic validation), useful for + projections like SELECT c.id, c.partitionKey. + """ + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + return [item async for item in items_iterable] + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception("query_projection_async", error) from error + + async def close_async(self) -> None: + """Close the container.""" + if self._database: + logger.debug("[CosmosContainer] Closing container '%s' in database '%s'", self._container_name, self._database_name) + await self._database.close_async() diff --git a/backend_py/primary/primary/persistence/cosmosdb/cosmos_database.py b/backend_py/primary/primary/persistence/cosmosdb/cosmos_database.py new file mode 100644 index 000000000..57b97cb78 --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/cosmos_database.py @@ -0,0 +1,70 @@ +import logging +from types import TracebackType +from typing import Optional, Type +from azure.cosmos.aio import CosmosClient, ContainerProxy +from azure.cosmos import exceptions + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY +from primary.services.service_exceptions import Service, ServiceRequestError + + +logger = logging.getLogger(__name__) + +class CosmosDatabase: + """ + CosmosDatabase provides access to a Cosmos DB database. + It allows for getting container proxies within the database. + + It is designed to be used with asynchronous context management, ensuring proper resource cleanup. + """ + + def __init__(self, database_name: str, client: CosmosClient): + self._database_name = database_name + self._client = client + self._database = self._client.get_database_client(database_name) + + @classmethod + def create_instance(cls, database_name: str) -> "CosmosDatabase": + if COSMOS_DB_PROD_CONNECTION_STRING: + client = CosmosClient.from_connection_string(COSMOS_DB_PROD_CONNECTION_STRING) + elif COSMOS_DB_EMULATOR_URI and COSMOS_DB_EMULATOR_KEY: + client = CosmosClient(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY, connection_verify=False) + else: + raise ServiceRequestError( + "No Cosmos DB production connection string or emulator URI/key provided.", Service.DATABASE + ) + self = cls(database_name, client) + return self + + async def __aenter__(self) -> "CosmosDatabase": + return self + + async def __aexit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: + await self.close_async() + + def _make_exception(self, message: str) -> ServiceRequestError: + return ServiceRequestError(f"CosmosDatabase ({self._database_name}): {message}", Service.DATABASE) + + def get_container(self, container_name: str) -> ContainerProxy: + if not self._client or not self._database: + raise self._make_exception("Database client is not initialized or already closed.") + if not container_name or not isinstance(container_name, str): + raise self._make_exception("Invalid container name.") + + try: + container = self._database.get_container_client(container_name) + return container + except exceptions.CosmosHttpResponseError as error: + raise self._make_exception(f"Unable to access container '{container_name}': {error.message}") from error + + async def close_async(self) -> None: + if self._client: + try: + logger.debug("[CosmosDatabase] Closing database client for '%s'", self._database_name) + await self._client.close() + logger.debug("[CosmosDatabase] Successfully closed database client for '%s'", self._database_name) + except Exception as e: + logger.error("[CosmosDatabase] Error closing database client for '%s': %s", self._database_name, e) + raise diff --git a/backend_py/primary/primary/persistence/cosmosdb/error_converter.py b/backend_py/primary/primary/persistence/cosmosdb/error_converter.py new file mode 100644 index 000000000..9606280c1 --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/error_converter.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Dict, NoReturn, Type + +from primary.services.service_exceptions import Service, ServiceRequestError + +from primary.persistence.cosmosdb.exceptions import ( + DatabaseAccessError, + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + +_DEFAULT_MESSAGES: Dict[Type[DatabaseAccessError], str] = { + DatabaseAccessNotFoundError: "Resource not found.", + DatabaseAccessConflictError: "Conflict while writing resource.", + DatabaseAccessPreconditionFailedError: "Precondition failed (ETag mismatch).", + DatabaseAccessPermissionError: "Permission denied for database operation.", + DatabaseAccessThrottledError: "Database is throttling requests; please retry later.", + DatabaseAccessTransportError: "Database transport error.", + DatabaseAccessError: "Database error.", +} + + +def convert_data_access_error_to_service_error( + err: DatabaseAccessError, +) -> ServiceRequestError: + """ + Convert a DatabaseAccess* error to a ServiceRequestError (without raising). + You can customize messages per exception type via the 'messages' dict. + """ + msgs = {**_DEFAULT_MESSAGES} + + # Find the most specific message for the concrete type + msg = None + for err_type, text in msgs.items(): + if isinstance(err, err_type): + msg = text + break + if msg is None: + msg = msgs[DatabaseAccessError] + + # Append context and technical details (status/substatus/activity_id) if available + details = [] + if getattr(err, "status_code", None) is not None: + details.append(f"status={err.status_code}") + if getattr(err, "sub_status", None) is not None: + details.append(f"substatus={err.sub_status}") + if getattr(err, "activity_id", None): + details.append(f"activity_id={err.activity_id}") + + suffix = f" ({', '.join(details)})" if details else "" + message = f"{msg}{suffix}" + + return ServiceRequestError(message, Service.DATABASE) + + +def raise_service_error_from_database_access( + err: DatabaseAccessError, +) -> NoReturn: + """ + Convert and raise immediately, chaining the original error. + """ + service_err = convert_data_access_error_to_service_error(err) + raise service_err from err diff --git a/backend_py/primary/primary/persistence/cosmosdb/exceptions.py b/backend_py/primary/primary/persistence/cosmosdb/exceptions.py new file mode 100644 index 000000000..ce1f2e6d3 --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/exceptions.py @@ -0,0 +1,41 @@ +class DatabaseAccessError(RuntimeError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + sub_status: int | None = None, + activity_id: str | None = None + ): + super().__init__(message) + self.status_code = status_code + self.sub_status = sub_status + self.activity_id = activity_id + + +class DatabaseAccessNotFoundError(DatabaseAccessError): + """Resource not found (404).""" + + +class DatabaseAccessConflictError(DatabaseAccessError): + """Conflict (409).""" + + +class DatabaseAccessPreconditionFailedError(DatabaseAccessError): + """Precondition failed / ETag mismatch (412).""" + + +class DatabaseAccessPermissionError(DatabaseAccessError): + """Auth/permission denied (401/403).""" + + +class DatabaseAccessThrottledError(DatabaseAccessError): + """Throttled / transient (429/503).""" + + +class DatabaseAccessTransportError(DatabaseAccessError): + """Other transport / HTTP errors.""" + + +class DatabaseAccessIntegrityError(DatabaseAccessError): + """Data integrity error.""" diff --git a/backend_py/primary/primary/persistence/cosmosdb/filter_factory.py b/backend_py/primary/primary/persistence/cosmosdb/filter_factory.py new file mode 100644 index 000000000..9618a939c --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/filter_factory.py @@ -0,0 +1,189 @@ +""" +Filter factory for creating type-safe filters validated against Pydantic models. + +This module provides a factory class that ensures filter fields exist in the document model +before creating Filter instances, preventing runtime errors from invalid field paths. +""" + +import logging +from typing import Type, get_args, get_origin, Optional + +from pydantic import BaseModel + +from primary.persistence.cosmosdb.query_collation_options import Filter, FilterOperator + + +LOGGER = logging.getLogger(__name__) + + +class FilterFactory: + """ + Factory for creating validated filters against a Pydantic model. + + This factory ensures that filter fields exist in the document model structure + before creating Filter instances, providing type safety and early error detection. + + Example: + ```python + from .documents import SessionDocument + + # Create factory for SessionDocument + factory = FilterFactory(SessionDocument) + + # Create validated filters + title_filter = factory.create("metadata.title", "My Title", "EQUAL") + date_filter = factory.create("metadata.created_at", "2024-01-01", "MORE") + + # Invalid field raises ValueError + try: + invalid = factory.create("metadata.nonexistent", "value") + except ValueError as e: + print(e) # "Field 'metadata.nonexistent' does not exist in SessionDocument" + ``` + """ + + def __init__(self, document_model: Type[BaseModel]): + """ + Initialize the filter factory with a document model. + + Args: + document_model: The Pydantic model to validate fields against + """ + self.document_model = document_model + self._field_cache: dict[str, bool] = {} + + def _field_exists(self, field_path: str) -> bool: + """ + Check if a field exists in the document model. + + Args: + field_path: Dot-notation field path (e.g., "metadata.title") + + Returns: + True if the field exists, False otherwise + """ + # Check cache first + if field_path in self._field_cache: + return self._field_cache[field_path] + + # Split path into parts + parts = field_path.split(".") + + # Navigate the model structure + current_model = self.document_model + + for part in parts: + if not hasattr(current_model, "__annotations__"): + self._field_cache[field_path] = False + return False + + field_type = current_model.__annotations__.get(part) + if field_type is None: + self._field_cache[field_path] = False + return False + + # Handle Optional types + origin = get_origin(field_type) + if origin is not None: + args = get_args(field_type) + if len(args) > 0: + field_type = args[0] + + # If this is the last part, we found the field + if part == parts[-1]: + self._field_cache[field_path] = True + return True + + # Continue navigation if this is a nested model + if not isinstance(field_type, type) or not issubclass(field_type, BaseModel): + self._field_cache[field_path] = False + return False + + current_model = field_type + + self._field_cache[field_path] = True + return True + + def _get_field_type(self, field_path: str) -> Optional[Type]: + """ + Get the type of a field in the document model. + + Args: + field_path: Dot-notation field path + + Returns: + The field type, or None if field doesn't exist + """ + parts = field_path.split(".") + current_model = self.document_model + + for part in parts: + if not hasattr(current_model, "__annotations__"): + return None + + field_type = current_model.__annotations__.get(part) + if field_type is None: + return None + + # Handle Optional types + origin = get_origin(field_type) + if origin is not None: + args = get_args(field_type) + if len(args) > 0: + field_type = args[0] + + # If this is the last part, return the type + if part == parts[-1]: + return field_type + + # Continue navigation + if not isinstance(field_type, type) or not issubclass(field_type, BaseModel): + return None + + current_model = field_type + + return None + + def create( + self, + field: str, + value: str | float, + operator: FilterOperator = "EQUAL", + prefix: str = "", + validate: bool = True, + ) -> Filter: + """ + Create a validated filter for the document model. + + Args: + field: Dot-notation field path (e.g., "metadata.title") + value: The value to filter by + operator: The filter operator (EQUAL, LESS, MORE, CONTAINS) + prefix: Optional prefix for the parameter name + validate: Whether to validate the field exists (default: True) + + Returns: + A validated Filter instance + + Raises: + ValueError: If the field doesn't exist in the document model (when validate=True) + + Example: + ```python + # Create an equality filter + filter = factory.create("metadata.title", "My Title") + + # Create a range filter + filter = factory.create("metadata.created_at", "2024-01-01", "MORE") + + # Create a contains filter for case-insensitive search + filter = factory.create("metadata.title__lower", "search", "CONTAINS") + ``` + """ + if validate and not self._field_exists(field): + raise ValueError( + f"Field '{field}' does not exist in {self.document_model.__name__}. " + f"Available fields can be checked using the model's __annotations__." + ) + + return Filter(field=field, value=value, operator=operator, prefix=prefix) diff --git a/backend_py/primary/primary/persistence/cosmosdb/query_collation_options.py b/backend_py/primary/primary/persistence/cosmosdb/query_collation_options.py new file mode 100644 index 000000000..ad1ec20e5 --- /dev/null +++ b/backend_py/primary/primary/persistence/cosmosdb/query_collation_options.py @@ -0,0 +1,145 @@ +from enum import Enum +from dataclasses import dataclass +import logging +from typing import Literal, Type, get_args, get_origin + +from pydantic import BaseModel + + +LOGGER = logging.getLogger(__name__) + + +class SortDirection(str, Enum): + ASC = "asc" + DESC = "desc" + + +LOWER_CASE_PREFIX = "__lower" +FilterOperator = Literal["CONTAINS", "EQUAL", "LESS", "MORE"] + + +@dataclass +class Filter: + field: str + value: str | float + operator: FilterOperator = "EQUAL" + prefix: str = "" + + @property + def prop_name(self) -> str: + return self.field.replace(".", "") + self.prefix + + +@dataclass +class QueryCollationOptions: + """Helper class for defining NoSQL collation options""" + + sort_by: str | None = None + sort_dir: SortDirection | None = None + sort_lowercase: bool | None = None + limit: int | None = None + offset: int | None = 0 + filters: list[Filter] | None = None + document_model: Type[BaseModel] | None = None + + def _field_has_lowercase_variant(self, field_path: str) -> bool: + """ + Check if a field has a lowercase variant in the document model. + + Args: + field_path: Dot-notation field path (e.g., "metadata.title") + + Returns: + True if the lowercase variant exists, False otherwise + """ + if self.document_model is None: + # No model provided - assume lowercase variant exists (backward compatible) + return True + + # Split path into parts + parts = field_path.split(".") + lowercase_field = f"{parts[-1]}{LOWER_CASE_PREFIX}" + + # Navigate the model structure + current_model = self.document_model + + # Navigate to the nested model (all parts except the last) + for part in parts[:-1]: + if not hasattr(current_model, "__annotations__"): + return False + + field_type = current_model.__annotations__.get(part) + if field_type is None: + return False + + # Handle Optional types + origin = get_origin(field_type) + if origin is not None: + args = get_args(field_type) + if len(args) > 0: + field_type = args[0] + + if not isinstance(field_type, type) or not issubclass(field_type, BaseModel): + return False + + current_model = field_type + + # Check if the lowercase variant exists in the final model + if not hasattr(current_model, "__annotations__"): + return False + + return lowercase_field in current_model.__annotations__ + + def is_any_collation(self) -> bool: + return self.sort_by is not None and self.limit is not None + + def make_query_params(self) -> list[dict[str, object]]: + if not self.filters: + return [] + + return [{"name": f"@{q_filter.prop_name}", "value": q_filter.value} for q_filter in self.filters] + + def to_sql_query_string(self, variable_name: str = "c") -> str | None: + tokens = [] + + if self.filters: + for idx, query_filter in enumerate(self.filters): + tokens.append("WHERE" if (idx == 0) else "AND") + + if query_filter.operator == "EQUAL": + tokens.append(f"{variable_name}.{query_filter.field} = @{query_filter.prop_name}") + elif query_filter.operator == "LESS": + tokens.append(f"{variable_name}.{query_filter.field} <= @{query_filter.prop_name}") + elif query_filter.operator == "MORE": + tokens.append(f"{variable_name}.{query_filter.field} >= @{query_filter.prop_name}") + elif query_filter.operator == "CONTAINS": + tokens.append(f"CONTAINS({variable_name}.{query_filter.field}, @{query_filter.prop_name})") + else: + raise NotImplementedError(f"{query_filter.operator} has not been implemented yet") + + if self.sort_by: + # Check if lowercase variant exists before adding suffix + if self.sort_lowercase and self._field_has_lowercase_variant(self.sort_by): + sort_by_field = self.sort_by + LOWER_CASE_PREFIX + else: + sort_by_field = self.sort_by + # Optional: Warn if lowercase was requested but not available + if self.sort_lowercase and self.document_model is not None: + LOGGER.warning( + f"Lowercase sorting requested for '{self.sort_by}', " + f"but '{self.sort_by}{LOWER_CASE_PREFIX}' field not found in document model. " + f"Using case-sensitive sorting instead.", + ) + + tokens.append(f"ORDER BY {variable_name}.{sort_by_field}") + + if self.sort_dir: + tokens.append(self.sort_dir.value) + + if self.limit is not None: + tokens.append(f"OFFSET {self.offset or 0} LIMIT {self.limit}") + + if tokens: + return " ".join(tokens) + + return None diff --git a/backend_py/primary/primary/persistence/session_store/__init__.py b/backend_py/primary/primary/persistence/session_store/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/persistence/session_store/documents.py b/backend_py/primary/primary/persistence/session_store/documents.py new file mode 100644 index 000000000..f85ded6a7 --- /dev/null +++ b/backend_py/primary/primary/persistence/session_store/documents.py @@ -0,0 +1,46 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict, computed_field + + +# CRITICAL: DATABASE SCHEMA - These models define the structure of session documents in Cosmos DB. +# Changes break existing data: renaming/removing fields breaks queries, changing types causes validation errors, +# making optional fields required breaks reads. Plan data migration first. Partition keys CANNOT be changed. + + +class SessionMetadata(BaseModel): + model_config = ConfigDict(extra="ignore") + + # Publicly editable fields + title: str + description: str | None + + # Internal fields not editable by user + created_at: datetime + updated_at: datetime + content_hash: str + version: int + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +class SessionDocument(BaseModel): + model_config = ConfigDict(extra="ignore") + + # id of the session document - has to be at top level - also used as partition key + id: str + + owner_id: str + metadata: SessionMetadata + content: str diff --git a/backend_py/primary/primary/persistence/session_store/session_store.py b/backend_py/primary/primary/persistence/session_store/session_store.py new file mode 100644 index 000000000..355ffeadb --- /dev/null +++ b/backend_py/primary/primary/persistence/session_store/session_store.py @@ -0,0 +1,240 @@ +from typing import List, Optional, Tuple +from datetime import datetime, timezone +from nanoid import generate + +from primary.persistence._utils import hash_session_content_string +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.persistence.cosmosdb.cosmos_container import CosmosContainer +from primary.persistence.cosmosdb.query_collation_options import Filter, QueryCollationOptions, SortDirection +from primary.persistence.session_store.types import SessionSortBy +from primary.persistence.cosmosdb.exceptions import DatabaseAccessError +from primary.persistence.cosmosdb.error_converter import raise_service_error_from_database_access + +from .documents import SessionDocument, SessionMetadata + +_CONTAINER_NAME = "sessions" +_DATABASE_NAME = "persistence" + + +class SessionStore: + """ + A simple data store for session documents with CRUD operations. + Supports pagination, sorting, filtering, and limits. + """ + + def __init__(self, user_id: str, session_container: CosmosContainer[SessionDocument]): + self._user_id = user_id + self._session_container = session_container + + async def __aenter__(self) -> "SessionStore": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + await self._session_container.close_async() + + @classmethod + def create_instance(cls, user_id: str) -> "SessionStore": + session_container = CosmosContainer.create_instance(_DATABASE_NAME, _CONTAINER_NAME, SessionDocument) + return cls(user_id=user_id, session_container=session_container) + + async def create_async(self, title: str, description: Optional[str], content: str) -> str: + """ + Create a new session document. + + Args: + title: The title of the session + description: The description of the session + content: The content of the session + + Returns: + The ID of the created session + + Raises: + DatabaseAccessError: If the database operation fails + """ + try: + now = datetime.now(timezone.utc) + session_id = str(generate(size=8)) + + session = SessionDocument( + id=session_id, + owner_id=self._user_id, + metadata=SessionMetadata( + title=title, + description=description, + created_at=now, + updated_at=now, + content_hash=hash_session_content_string(content), + version=1, + ), + content=content, + ) + + return await self._session_container.insert_item_async(session) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_async(self, session_id: str) -> SessionDocument: + """ + Read a single session by ID. + + Args: + session_id: The ID of the session to retrieve + + Returns: + The session document + + Raises: + ServiceRequestError: If the user doesn't own the session + DatabaseAccessError: If the database operation fails + """ + try: + document = await self._session_container.get_item_async(item_id=session_id, partition_key=self._user_id) + + # Verify ownership + if document.owner_id != self._user_id: + raise ServiceRequestError( + f"You do not have permission to access session '{session_id}'.", + Service.DATABASE, + ) + + return document + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_many_async( + self, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + sort_by: Optional[SessionSortBy] = None, + sort_direction: Optional[SortDirection] = None, + sort_lowercase: bool = False, + filters: Optional[List[Filter]] = None, + ) -> Tuple[List[SessionDocument], Optional[str]]: + """ + Read multiple sessions with support for pagination, sorting, filtering, and limits. + + Args: + page_token: Token for pagination (if using page-based pagination) + page_size: Number of items per page (for page-based pagination) + sort_by: Field name to sort by + sort_direction: Direction to sort (ASC or DESC) + sort_lowercase: Whether to use case-insensitive sorting + filters: List of filters to apply + + Returns: + Tuple of (list of session documents, continuation token for next page) + + Raises: + DatabaseAccessError: If the database operation fails + """ + try: + # Always filter by owner_id + filter_list = filters or [] + filter_list.insert(0, Filter("owner_id", self._user_id)) + + # Build query with collation options + collation_options = QueryCollationOptions( + sort_lowercase=sort_lowercase, + sort_dir=sort_direction, + sort_by=sort_by.value if sort_by else None, + filters=filter_list, + document_model=SessionDocument, + ) + + query = "SELECT * FROM c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self._session_container.query_items_by_page_token_async( + query=query, + parameters=params, + page_size=page_size, + page_token=page_token, + ) + + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def update_async( + self, + session_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + content: Optional[str] = None, + ) -> SessionDocument: + """ + Update an existing session document with partial updates. + + Args: + session_id: The ID of the session to update + title: The new title for the session + description: The new description for the session + content: The new content for the session + + Returns: + The updated session document + + Raises: + ServiceRequestError: If the user doesn't own the session + DatabaseAccessError: If the database operation fails + ValidationError: If updates contain invalid field names or values + """ + try: + # Verify ownership and get existing document + existing = await self.get_async(session_id) + + # Apply partial updates + updated_session = existing.model_copy() + + if title is not None: + updated_session.metadata.title = title + + if description is not None: + updated_session.metadata.description = description + + # Ensure critical fields are preserved + updated_session.id = session_id + updated_session.owner_id = self._user_id + + # Update managed metadata fields + updated_session.metadata.updated_at = datetime.now(timezone.utc) + updated_session.metadata.version = existing.metadata.version + 1 + + # Recompute hash if content changed + if content is not None: + updated_session.content = content + updated_session.metadata.content_hash = hash_session_content_string(content) + + await self._session_container.update_item_async(session_id, updated_session) + + return updated_session + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def delete_async(self, session_id: str) -> None: + """ + Delete a session document. + + Args: + session_id: The ID of the session to delete + + Raises: + ServiceRequestError: If the user doesn't own the session + DatabaseAccessError: If the database operation fails + """ + try: + # Verify ownership before deletion + await self.get_async(session_id) + + await self._session_container.delete_item_async(session_id, partition_key=self._user_id) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) diff --git a/backend_py/primary/primary/persistence/session_store/types.py b/backend_py/primary/primary/persistence/session_store/types.py new file mode 100644 index 000000000..f993e1d40 --- /dev/null +++ b/backend_py/primary/primary/persistence/session_store/types.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class SessionSortBy(str, Enum): + CREATED_AT = "metadata.created_at" + UPDATED_AT = "metadata.updated_at" + TITLE = "metadata.title" diff --git a/backend_py/primary/primary/persistence/setup_local_database.py b/backend_py/primary/primary/persistence/setup_local_database.py new file mode 100644 index 000000000..833b9dba7 --- /dev/null +++ b/backend_py/primary/primary/persistence/setup_local_database.py @@ -0,0 +1,112 @@ +""" +This file is only used for setting up the local database for development and testing purposes. +""" + +import logging +import time +from typing import List, Dict, Any +import ssl +import urllib.request +from urllib.error import URLError + +from azure.cosmos import CosmosClient, PartitionKey, DatabaseProxy + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY + +LOGGER = logging.getLogger(__name__) + +# Declarative schema definition +COSMOS_SCHEMA: List[Dict[str, Any]] = [ + { + "database": "persistence", + "offer_throughput": 4000, + "containers": [ + {"id": "sessions", "partition_key": "/owner_id"}, + {"id": "snapshots", "partition_key": "/id"}, + {"id": "snapshot_access_logs", "partition_key": "/visitor_id"}, + ], + }, +] + + +def wait_for_emulator(uri: str, key: str, retries: int = 50, delay: int = 10) -> CosmosClient: + probe_url = f"{uri.rstrip('/')}/_explorer/emulator.pem" + # pylint: disable=protected-access + # Disabling SSL certificate verification is safe here because this code is used exclusively + # with the local Cosmos DB Emulator for development and testing. Never use this in production. + context = ssl._create_unverified_context() # nosec + + for attempt in range(retries): + try: + with urllib.request.urlopen(probe_url, context=context) as response: # nosec + if response.status == 200: + LOGGER.info("✅ Emulator HTTPS endpoint is up. Proceeding to create CosmosClient.") + break + except URLError as e: + LOGGER.warning("⏳ Emulator cert endpoint not ready (attempt %d): %s", attempt + 1, e.reason) + time.sleep(delay) + else: + raise RuntimeError("❌ Cosmos Emulator certificate endpoint not ready after timeout") + + # Now that we know HTTPS works, create the CosmosClient + return CosmosClient(uri, key, connection_verify=False) + + +def create_database_with_retry(client: CosmosClient, db_def: Dict[str, Any], max_attempts: int = 5) -> DatabaseProxy: + db_name: str = db_def["database"] + for attempt in range(1, max_attempts + 1): + try: + return client.create_database_if_not_exists(db_name, offer_throughput=db_def.get("offer_throughput")) + # pylint: disable=broad-except + except Exception as error: + LOGGER.warning("⚠️ Failed to create database '%s' (attempt %d): %s", db_name, attempt, error) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + raise RuntimeError(f"Failed to create database '{db_name}' after {max_attempts} attempts.") + + +def maybe_setup_local_database() -> None: + if COSMOS_DB_PROD_CONNECTION_STRING: + LOGGER.info("Using production Cosmos DB - skipping local setup.") + return + + if COSMOS_DB_EMULATOR_URI is None or COSMOS_DB_EMULATOR_KEY is None: + raise ValueError("No Cosmos DB production connection string or emulator URI/key provided.") + + client: CosmosClient = wait_for_emulator(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY) + + total_containers = 0 + + for db_def in COSMOS_SCHEMA: + database: DatabaseProxy = create_database_with_retry(client, db_def) + + for container_def in db_def["containers"]: + max_attempts = 5 + for attempt in range(1, max_attempts + 1): + try: + database.create_container_if_not_exists( + id=container_def["id"], + partition_key=PartitionKey(path=container_def["partition_key"]), + offer_throughput=container_def.get("throughput"), + indexing_policy=container_def.get("indexing_policy"), + ) + LOGGER.info(" ✅ Created container '%s' (attempt %d)", container_def["id"], attempt) + break + # pylint: disable=broad-except + except Exception as error: + LOGGER.warning( + " ⚠️ Failed to create container '%s' (attempt %d): %s", container_def["id"], attempt, error + ) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + total_containers += 1 + + LOGGER.info( + "✅ Local Cosmos DB emulator setup complete: %d database(s), %d container(s).", + len(COSMOS_SCHEMA), + total_containers, + ) diff --git a/backend_py/primary/primary/persistence/snapshot_store/__init__.py b/backend_py/primary/primary/persistence/snapshot_store/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/persistence/snapshot_store/documents.py b/backend_py/primary/primary/persistence/snapshot_store/documents.py new file mode 100644 index 000000000..465cc5e5d --- /dev/null +++ b/backend_py/primary/primary/persistence/snapshot_store/documents.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict, computed_field + + +# CRITICAL: DATABASE SCHEMA - These models define the structure of snapshot and snapshot access log documents in Cosmos DB. +# Changes break existing data: renaming/removing fields breaks queries, changing types causes validation errors, +# making optional fields required breaks reads. Plan data migration first. Partition keys CANNOT be changed. + + +class SnapshotMetadata(BaseModel): + title: str + description: Optional[str] = None + created_at: datetime + content_hash: str + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +class SnapshotDocument(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str # id of the snapshot document - has to be at top level - also used as partition key + owner_id: str + metadata: SnapshotMetadata + content: str + + +class SnapshotAccessLogDocument(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str # id of the access log document - has to be at top level + visitor_id: str # user id of the visitor - also used as partition key + snapshot_id: str + snapshot_owner_id: str + visits: int = 0 + first_visited_at: datetime | None = None + last_visited_at: datetime | None = None + snapshot_deleted: bool = False + snapshot_deleted_at: datetime | None = None + + snapshot_metadata: SnapshotMetadata diff --git a/backend_py/primary/primary/persistence/snapshot_store/snapshot_access_log_store.py b/backend_py/primary/primary/persistence/snapshot_store/snapshot_access_log_store.py new file mode 100644 index 000000000..697b08f11 --- /dev/null +++ b/backend_py/primary/primary/persistence/snapshot_store/snapshot_access_log_store.py @@ -0,0 +1,225 @@ +from datetime import datetime, timezone +from typing import List, Optional, Tuple + + +from primary.persistence.snapshot_store.types import SnapshotAccessLogSortBy +from primary.persistence.cosmosdb.cosmos_container import CosmosContainer +from primary.persistence.cosmosdb.exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.services.service_exceptions import Service, ServiceRequestError + +from primary.persistence.cosmosdb.query_collation_options import Filter, QueryCollationOptions, SortDirection +from .documents import SnapshotAccessLogDocument + +from .snapshot_store import SnapshotStore + +_DATABASE_NAME = "persistence" +_CONTAINER_NAME = "snapshot_access_logs" + + +class SnapshotAccessLogStore: + """ + Specialized store for logging snapshot visits by users. + + This is not a general CRUD store - it contains domain-specific business logic + for tracking and managing snapshot access logs. + """ + + def __init__( + self, + user_id: str, + access_log_container: CosmosContainer[SnapshotAccessLogDocument], + ): + self._user_id = user_id + self._access_log_container = access_log_container + + @classmethod + def create_instance(cls, user_id: str) -> "SnapshotAccessLogStore": + access_log_container = CosmosContainer.create_instance( + _DATABASE_NAME, _CONTAINER_NAME, SnapshotAccessLogDocument + ) + return cls(user_id, access_log_container) + + async def __aenter__(self) -> "SnapshotAccessLogStore": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + await self._access_log_container.close_async() + + async def get_for_snapshot_async(self, snapshot_id: str) -> SnapshotAccessLogDocument: + """ + Get the access log for a specific snapshot. + + Args: + snapshot_id: The ID of the snapshot + + Returns: + The access log document for this user and snapshot + + Raises: + DatabaseAccessNotFoundError: If no log exists + DatabaseAccessError: If the database operation fails + """ + item_id = _make_access_log_item_id(snapshot_id, self._user_id) + return await self._access_log_container.get_item_async(item_id, partition_key=self._user_id) + + async def get_many_for_user_async( + self, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + sort_by: Optional[SnapshotAccessLogSortBy] = None, + sort_direction: Optional[SortDirection] = None, + sort_lowercase: bool = False, + filters: Optional[List[Filter]] = None, + ) -> Tuple[List[SnapshotAccessLogDocument], Optional[str]]: + """ + Get multiple access logs with support for pagination, sorting, filtering, and limits. + + Args: + page_token: Token for pagination (if using page-based pagination) + page_size: Number of items per page (for page-based pagination) + sort_by: Field name to sort by (e.g., "snapshot_metadata.title") + sort_direction: Direction to sort (ASC or DESC) + sort_lowercase: Whether to use case-insensitive sorting + filters: List of filters to apply + + Returns: + Tuple of (list of access log documents, continuation token for next page) + + Raises: + DatabaseAccessError: If the database operation fails + """ + try: + # Always filter by visitor_id (current user) + filter_list = filters or [] + filter_list.insert(0, Filter("visitor_id", self._user_id)) + + # Build query with collation options + collation_options = QueryCollationOptions( + sort_lowercase=sort_lowercase, + sort_dir=sort_direction, + sort_by=sort_by.value if sort_by else None, + filters=filter_list, + document_model=SnapshotAccessLogDocument, + ) + + query = "SELECT * FROM c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self._access_log_container.query_items_by_page_token_async( + query=query, + parameters=params, + page_size=page_size, + page_token=page_token, + ) + + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to get access logs: {str(err)}", Service.DATABASE) from err + + async def _create_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + """ + Create a new access log entry for a snapshot and persist it to the database. + + Args: + snapshot_id: The ID of the snapshot + snapshot_owner_id: The owner ID of the snapshot + + Returns: + The created and persisted access log document + + Raises: + ServiceRequestError: If unable to retrieve snapshot metadata or create log + """ + try: + # Use SnapshotStore to get snapshot metadata + async with SnapshotStore.create_instance(self._user_id) as snapshot_store: + snapshot = await snapshot_store.get_async(snapshot_id) + + new_log = SnapshotAccessLogDocument( + id=_make_access_log_item_id(snapshot_id, self._user_id), + visitor_id=self._user_id, + snapshot_id=snapshot_id, + snapshot_owner_id=snapshot_owner_id, + snapshot_metadata=snapshot.metadata, + ) + + # Persist to database + await self._access_log_container.insert_item_async(new_log) + + return new_log + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to create access log: {str(err)}", Service.DATABASE) from err + + async def _get_existing_or_new_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + """ + Get an existing access log or create a new one if it doesn't exist. + + Note: This DOES persist a new log to the database if one doesn't exist. + + Args: + snapshot_id: The ID of the snapshot + snapshot_owner_id: The owner ID of the snapshot + + Returns: + Existing or newly created access log document + + Raises: + ServiceRequestError: If the database operation fails + """ + try: + return await self.get_for_snapshot_async(snapshot_id) + except DatabaseAccessNotFoundError: + return await self._create_async(snapshot_id=snapshot_id, snapshot_owner_id=snapshot_owner_id) + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to get or create access log: {str(err)}", Service.DATABASE) from err + + async def log_snapshot_visit_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + """ + Log a visit to a snapshot, creating or updating the access log. + + This is the main method for tracking snapshot visits. It: + - Retrieves or creates an access log + - Increments the visit count + - Updates the last visited timestamp + - Sets the first visited timestamp if this is the first visit + - Persists the changes to the database + + Args: + snapshot_id: The ID of the snapshot being visited + snapshot_owner_id: The owner ID of the snapshot + + Returns: + The updated access log document + + Raises: + ServiceRequestError: If the database operation fails + """ + timestamp = datetime.now(timezone.utc) + try: + log = await self._get_existing_or_new_async(snapshot_id, snapshot_owner_id) + + # Update visit tracking + log.visits += 1 + log.last_visited_at = timestamp + + if not log.first_visited_at: + log.first_visited_at = timestamp + + # Persist to database + await self._access_log_container.update_item_async(item_id=log.id, updated_item=log) + + return log + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to log snapshot visit: {str(err)}", Service.DATABASE) from err + + +def _make_access_log_item_id(snapshot_id: str, visitor_id: str) -> str: + return f"{snapshot_id}__{visitor_id}" diff --git a/backend_py/primary/primary/persistence/snapshot_store/snapshot_store.py b/backend_py/primary/primary/persistence/snapshot_store/snapshot_store.py new file mode 100644 index 000000000..504c5b2b9 --- /dev/null +++ b/backend_py/primary/primary/persistence/snapshot_store/snapshot_store.py @@ -0,0 +1,191 @@ +from typing import List, Optional, Tuple, Type +from datetime import datetime, timezone +from nanoid import generate + +from primary.persistence._utils import hash_session_content_string +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.persistence.cosmosdb.query_collation_options import Filter, QueryCollationOptions, SortDirection +from primary.persistence.cosmosdb.cosmos_container import CosmosContainer +from primary.persistence.cosmosdb.exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.persistence.cosmosdb.error_converter import raise_service_error_from_database_access + +from .documents import SnapshotDocument, SnapshotMetadata +from .types import SnapshotSortBy + +_CONTAINER_NAME = "snapshots" +_DATABASE_NAME = "persistence" + + +class SnapshotStore: + """ + A simple data store for snapshot documents with CRUD operations. + Supports pagination, sorting, filtering, and limits. + """ + + def __init__( + self, + user_id: str, + snapshot_container: CosmosContainer[SnapshotDocument], + ): + self._user_id = user_id + self._snapshot_container = snapshot_container + + async def __aenter__(self) -> "SnapshotStore": + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[object], + ) -> None: + await self._snapshot_container.close_async() + + @classmethod + def create_instance(cls, user_id: str) -> "SnapshotStore": + snapshot_container = CosmosContainer.create_instance(_DATABASE_NAME, _CONTAINER_NAME, SnapshotDocument) + return cls(user_id, snapshot_container) + + async def create_async(self, title: str, description: Optional[str], content: str) -> str: + """ + Create a new snapshot document. + + Args: + title: The title of the snapshot + description: The description of the snapshot + content: The content of the snapshot + + Returns: + The ID of the created snapshot + + Raises: + DatabaseAccessError: If the database operation fails + """ + try: + now = datetime.now(timezone.utc) + snapshot_id = generate(size=8) + + snapshot = SnapshotDocument( + id=snapshot_id, + owner_id=self._user_id, + metadata=SnapshotMetadata( + title=title, + description=description, + created_at=now, + content_hash=hash_session_content_string(content), + ), + content=content, + ) + + return await self._snapshot_container.insert_item_async(snapshot) + except DatabaseAccessError as e: + raise_service_error_from_database_access(e) + + async def get_async(self, snapshot_id: str) -> SnapshotDocument: + """ + Get a single snapshot by ID. + + Args: + snapshot_id: The ID of the snapshot to retrieve + + Returns: + The snapshot document + + Raises: + ServiceRequestError: If the snapshot is not found or user doesn't own it + DatabaseAccessError: If the database operation fails + """ + try: + document = await self._snapshot_container.get_item_async(item_id=snapshot_id, partition_key=snapshot_id) + + # Verify ownership + if document.owner_id != self._user_id: + raise ServiceRequestError( + f"You do not have permission to access snapshot '{snapshot_id}'.", + Service.DATABASE, + ) + + return document + except DatabaseAccessNotFoundError as e: + raise ServiceRequestError( + f"Snapshot with id '{snapshot_id}' not found. It might have been deleted.", + Service.DATABASE, + ) from e + except DatabaseAccessError as e: + raise_service_error_from_database_access(e) + + async def get_many_async( + self, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + sort_by: Optional[SnapshotSortBy] = None, + sort_direction: Optional[SortDirection] = None, + sort_lowercase: bool = False, + filters: Optional[List[Filter]] = None, + ) -> Tuple[List[SnapshotDocument], Optional[str]]: + """ + Get multiple snapshots with support for pagination, sorting, filtering, and limits. + + Args: + page_token: Token for pagination (if using page-based pagination) + page_size: Number of items per page (for page-based pagination) + sort_by: Field name to sort by + sort_direction: Direction to sort (ASC or DESC) + sort_lowercase: Whether to use case-insensitive sorting + filters: List of filters to apply + + Returns: + Tuple of (list of snapshot documents, continuation token for next page) + + Raises: + DatabaseAccessError: If the database operation fails + """ + try: + # Always filter by owner_id + filter_list = filters or [] + filter_list.insert(0, Filter("owner_id", self._user_id)) + + # Build query with collation options + collation_options = QueryCollationOptions( + sort_lowercase=sort_lowercase, + sort_dir=sort_direction, + sort_by=sort_by.value if sort_by else None, + filters=filter_list, + document_model=SnapshotDocument, + ) + + query = "SELECT * FROM c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self._snapshot_container.query_items_by_page_token_async( + query=query, + parameters=params, + page_size=page_size, + page_token=page_token, + ) + + except DatabaseAccessError as e: + raise_service_error_from_database_access(e) + + async def delete_async(self, snapshot_id: str) -> None: + """ + Delete a snapshot document. + + Args: + snapshot_id: The ID of the snapshot to delete + + Raises: + ServiceRequestError: If the user doesn't own the snapshot + DatabaseAccessError: If the database operation fails + """ + try: + # Verify ownership before deletion + await self.get_async(snapshot_id) + + await self._snapshot_container.delete_item_async(snapshot_id, partition_key=snapshot_id) + except DatabaseAccessError as e: + raise_service_error_from_database_access(e) diff --git a/backend_py/primary/primary/persistence/snapshot_store/types.py b/backend_py/primary/primary/persistence/snapshot_store/types.py new file mode 100644 index 000000000..dc777ca71 --- /dev/null +++ b/backend_py/primary/primary/persistence/snapshot_store/types.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class SnapshotSortBy(str, Enum): + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + TITLE_LOWER = "title_lower" + + +class SnapshotAccessLogSortBy(str, Enum): + VISITS = "visits" + LAST_VISIT = "last_visited_at" + TITLE = "snapshot_metadata.title" + TITLE_LOWER = "snapshot_metadata.title__lower" + CREATED_AT = "snapshot_metadata.created_at" diff --git a/backend_py/primary/primary/persistence/tasks/mark_logs_deleted_task.py b/backend_py/primary/primary/persistence/tasks/mark_logs_deleted_task.py new file mode 100644 index 000000000..2c9a36c8e --- /dev/null +++ b/backend_py/primary/primary/persistence/tasks/mark_logs_deleted_task.py @@ -0,0 +1,83 @@ +import asyncio +from datetime import datetime, timezone +import logging +from typing import Any, Dict, List, Sequence + +from primary.persistence.cosmosdb.cosmos_container import CosmosContainer +from primary.persistence.snapshot_store.documents import SnapshotAccessLogDocument +from primary.services.service_exceptions import ServiceRequestError + +LOGGER = logging.getLogger(__name__) + +_DATABASE_NAME = "persistence" +_CONTAINER_NAME = "snapshot_access_logs" + +# To avoid overwhelming the database with too many concurrent PATCH operations +# (which can lead to throttling or Request Unit (RU) spikes), we limit concurrency. +_MAX_CONCURRENT_PATCH_OPS = 32 + + +async def mark_logs_deleted_task(snapshot_id: str) -> None: + """ + Marks all access-log docs for the given snapshot_id as deleted (PATCH /snapshot_deleted = true). + Runs with bounded concurrency and is idempotent/safe to re-run. + """ + container: CosmosContainer[SnapshotAccessLogDocument] = CosmosContainer.create_instance( + _DATABASE_NAME, _CONTAINER_NAME, SnapshotAccessLogDocument + ) + + try: + query = ( + "SELECT c.id, c.visitor_id " + "FROM c " + "WHERE c.snapshot_id = @sid " + "AND (NOT IS_DEFINED(c.snapshot_deleted) OR c.snapshot_deleted != true)" + ) + params = [{"name": "@sid", "value": snapshot_id}] + + rows: List[Dict[str, Any]] = await container.query_projection_async(query, params) + + if not rows: + LOGGER.info("No snapshot_access_logs to update for snapshot '%s'.", snapshot_id) + return + + deleted_at = datetime.now(timezone.utc).isoformat() + + operations: List[dict] = [ + {"op": "set", "path": "/snapshot_deleted", "value": True}, + {"op": "set", "path": "/snapshot_deleted_at", "value": deleted_at}, + ] + + # Limit concurrency to avoid RU spikes/throttling + sem = asyncio.Semaphore(_MAX_CONCURRENT_PATCH_OPS) + + async def _patch_one(rec: Dict[str, Any]) -> bool: + async with sem: + try: + await container.patch_item_async( + item_id=rec["id"], + partition_key=rec["visitor_id"], # /visitor_id is the PK + patch_operations=operations, + ) + return True + except asyncio.CancelledError: + # always re-raise cancellation so shutdowns are graceful + raise + except ServiceRequestError as e: + LOGGER.warning("PATCH failed for log id=%s pk=%s: %s", rec.get("id"), rec.get("visitor_id"), e) + # Do not re-raise - we want to continue with other items + return False + + results = await asyncio.gather(*(_patch_one(r) for r in rows)) + success = sum(1 for ok in results if ok) + fail = len(rows) - success + LOGGER.info( + "Marked %d/%d access-log docs deleted for snapshot '%s' (failures=%d).", + success, + len(rows), + snapshot_id, + fail, + ) + + finally: + await container.close_async() diff --git a/backend_py/primary/primary/routers/persistence/__init__.py b/backend_py/primary/primary/routers/persistence/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/routers/persistence/converters.py b/backend_py/primary/primary/routers/persistence/converters.py new file mode 100644 index 000000000..c9c19251f --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/converters.py @@ -0,0 +1,64 @@ +from primary.persistence.snapshot_store.documents import SnapshotAccessLogDocument, SnapshotDocument +from primary.persistence.session_store.documents import SessionDocument +from . import schemas + + +def to_api_session_metadata(session: SessionDocument) -> schemas.SessionMetadata: + return schemas.SessionMetadata( + id=session.id, + ownerId=session.owner_id, + title=session.metadata.title, + description=session.metadata.description, + createdAt=session.metadata.created_at.isoformat(), + updatedAt=session.metadata.updated_at.isoformat(), + version=session.metadata.version, + contentHash=session.metadata.content_hash, + ) + + +def to_api_session(document: SessionDocument) -> schemas.Session: + return schemas.Session( + metadata=to_api_session_metadata(document), + content=document.content, + ) + + +def to_api_snapshot_metadata(snapshot: SnapshotDocument) -> schemas.SnapshotMetadata: + return schemas.SnapshotMetadata( + id=snapshot.id, + ownerId=snapshot.owner_id, + title=snapshot.metadata.title, + description=snapshot.metadata.description, + createdAt=snapshot.metadata.created_at.isoformat(), + contentHash=snapshot.metadata.content_hash, + ) + + +def to_api_snapshot(snapshot: SnapshotDocument) -> schemas.Snapshot: + return schemas.Snapshot( + metadata=to_api_snapshot_metadata(snapshot), + content=snapshot.content, + ) + + +def to_api_access_log_snapshot_metadata(access_log: SnapshotAccessLogDocument) -> schemas.SnapshotMetadata: + return schemas.SnapshotMetadata( + id=access_log.snapshot_id, + ownerId=access_log.snapshot_owner_id, + title=access_log.snapshot_metadata.title, + description=access_log.snapshot_metadata.description, + createdAt=access_log.snapshot_metadata.created_at.isoformat(), + contentHash=access_log.snapshot_metadata.content_hash, + ) + + +def to_api_snapshot_access_log(access_log: SnapshotAccessLogDocument) -> schemas.SnapshotAccessLog: + return schemas.SnapshotAccessLog( + visitorId=access_log.visitor_id, + snapshotId=access_log.snapshot_id, + visits=access_log.visits, + firstVisitedAt=access_log.first_visited_at.isoformat() if access_log.first_visited_at else None, + lastVisitedAt=access_log.last_visited_at.isoformat() if access_log.last_visited_at else None, + snapshotDeleted=access_log.snapshot_deleted, + snapshotMetadata=to_api_access_log_snapshot_metadata(access_log), + ) diff --git a/backend_py/primary/primary/routers/persistence/router.py b/backend_py/primary/primary/routers/persistence/router.py new file mode 100644 index 000000000..266e52b24 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/router.py @@ -0,0 +1,419 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, Query + +from primary.persistence.session_store.documents import SessionDocument +from primary.persistence.snapshot_store.documents import SnapshotAccessLogDocument, SnapshotDocument +from primary.persistence.cosmosdb.filter_factory import FilterFactory +from primary.persistence.session_store.session_store import SessionStore +from primary.persistence.session_store.types import SessionSortBy +from primary.persistence.tasks.mark_logs_deleted_task import mark_logs_deleted_task +from primary.persistence.snapshot_store.snapshot_store import SnapshotStore +from primary.persistence.snapshot_store.snapshot_access_log_store import SnapshotAccessLogStore +from primary.persistence.cosmosdb.query_collation_options import SortDirection +from primary.persistence.snapshot_store.types import ( + SnapshotAccessLogSortBy, + SnapshotSortBy, +) +from primary.middleware.add_browser_cache import no_cache + + +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser +from .converters import ( + to_api_session_metadata, + to_api_session, + to_api_snapshot, + to_api_snapshot_access_log, + to_api_snapshot_metadata, +) + + +from . import schemas + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/sessions") +@no_cache +async def get_sessions_metadata( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + cursor: Optional[str] = Query(None, description="Continuation token for pagination"), + sort_by: Optional[SessionSortBy] = Query(None, description="Field to sort by (e.g., 'metadata.title')"), + sort_direction: Optional[SortDirection] = Query(None, description="Sort direction: 'asc' or 'desc'"), + sort_lowercase: bool = Query(False, description="Use case-insensitive sorting"), + page_size: int = Query(10, ge=1, le=100, description="Limit the number of results"), + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_updated_from: Optional[str] = Query(None, description="Filter results by date"), + filter_updated_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.Page[schemas.SessionMetadata]: + """ + Get a paginated list of session metadata for the authenticated user. + + This endpoint returns session metadata (without content) with support for: + - **Pagination**: Use the continuation token to fetch subsequent pages + - **Sorting**: Sort by various fields in ascending or descending order + - **Case-insensitive sorting**: Optional lowercase sorting for text fields + - **Filtering**: Filter by title and date ranges + + The response includes a continuation token for fetching the next page of results. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + filter_factory = FilterFactory(SessionDocument) + filters = [] + if filter_title: + filters.append(filter_factory.create("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(filter_factory.create("metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(filter_factory.create("metadata.updated_at", filter_updated_to, "LESS", "_to")) + + items, token = await session_store.get_many_async( + page_token=cursor, + page_size=page_size, + sort_by=sort_by, + sort_direction=sort_direction, + sort_lowercase=sort_lowercase, + filters=filters if filters else None, + ) + + return schemas.Page(items=[to_api_session_metadata(item) for item in items], pageToken=token) + + +@router.get("/sessions/{session_id}") +@no_cache +async def get_session( + session_id: str, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.Session: + """ + Retrieve a complete session by its ID. + + Returns the full session document including: + - Session metadata (title, description, timestamps, version, etc.) + - Complete session content + + Only the session owner can access this endpoint. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + session = await session_store.get_async(session_id) + return to_api_session(session) + + +@router.get("/sessions/metadata/{session_id}") +@no_cache +async def get_session_metadata( + session_id: str, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.SessionMetadata: + """ + Retrieve only the metadata for a specific session. + + Returns session metadata without the content, useful for: + - Listing sessions with details + - Checking version or timestamps + - Lightweight operations that don't need full content + + Only the session owner can access this endpoint. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + session = await session_store.get_async(session_id) + return to_api_session_metadata(session) + + +@router.post("/sessions") +async def create_session( + session: schemas.NewSession, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> str: + """ + Create a new session for the authenticated user. + + Provide: + - **title**: Session title (required) + - **description**: Optional description + - **content**: Session content (required) + + The system automatically generates: + - Unique session ID + - Creation and update timestamps + - Version number (starts at 1) + - Content hash for integrity checking + + Returns the ID of the newly created session. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + session_id = await session_store.create_async( + title=session.title, description=session.description, content=session.content + ) + return session_id + + +@router.put("/sessions/{session_id}") +async def update_session( + session_id: str, + session_update: schemas.SessionUpdate, + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> schemas.Session: + """ + Update an existing session with partial or complete changes. + + You can update any combination of: + - **title**: New session title + - **description**: New description + - **content**: New session content + + All fields are optional - only provided fields will be updated. + + The system automatically: + - Updates the `updated_at` timestamp + - Increments the version number + - Recalculates the content hash if content changed + - Preserves ownership and creation metadata + + Returns the complete updated session. + + Only the session owner can update their sessions. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + updated_session = await session_store.update_async( + session_id, + title=session_update.title, + description=session_update.description, + content=session_update.content, + ) + return to_api_session(updated_session) + + +@router.delete("/sessions/{session_id}") +async def delete_session( + session_id: str, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> None: + """ + Permanently delete a session. + + This operation: + - Removes the session document from the database + - Cannot be undone + - Requires ownership verification + + Only the session owner can delete their sessions. + """ + session_store = SessionStore.create_instance(authenticated_user.get_user_id()) + async with session_store: + await session_store.delete_async(session_id) + + +@router.get("/snapshot_access_logs") +@no_cache +# pylint: disable=too-many-arguments +async def get_snapshot_access_logs( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + cursor: Optional[str] = Query(None, description="Continuation token for pagination"), + page_size: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), + sort_by: Optional[SnapshotAccessLogSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(None, description="Sort direction: 'asc' or 'desc'"), + sort_lowercase: bool = Query(False, description="Use case-insensitive sorting"), + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_created_from: Optional[str] = Query(None, description="Filter results by date"), + filter_created_to: Optional[str] = Query(None, description="Filter results by date"), + filter_last_visited_from: Optional[str] = Query(None, description="Filter results by date of last visit"), + filter_last_visited_to: Optional[str] = Query(None, description="Filter results by date of last visit"), +) -> schemas.Page[schemas.SnapshotAccessLog]: + """ + Get a list of all snapshots you have visited. + + This endpoint tracks your interaction history with snapshots, including: + - Snapshots you've created (counted as implicit visits) + - Snapshots you've viewed + - Snapshots shared with you that you've accessed + + Each access log entry includes: + - **Visit count**: Number of times you've viewed the snapshot + - **First visited**: Timestamp of your first visit + - **Last visited**: Timestamp of your most recent visit + - **Snapshot metadata**: Title, description, creation date + - **Deletion status**: Whether the snapshot has been deleted + + Supports pagination, sorting, and filtering by: + - Title (case insensitive) + - Creation date range + - Last visited date range + """ + log_store = SnapshotAccessLogStore.create_instance(authenticated_user.get_user_id()) + + async with log_store: + filter_factory = FilterFactory(SnapshotAccessLogDocument) + filters = [] + if filter_title: + filters.append(filter_factory.create("snapshot_metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_created_from: + filters.append(filter_factory.create("snapshot_metadata.created_at", filter_created_from, "MORE", "_from")) + if filter_created_to: + filters.append(filter_factory.create("snapshot_metadata.created_at", filter_created_to, "LESS", "_to")) + if filter_last_visited_from: + filters.append(filter_factory.create("last_visited_at", filter_last_visited_from, "MORE", "_from")) + if filter_last_visited_to: + filters.append(filter_factory.create("last_visited_at", filter_last_visited_to, "LESS", "_to")) + + (items, cont_token) = await log_store.get_many_for_user_async( + page_token=cursor, + page_size=page_size, + sort_by=sort_by, + sort_direction=sort_direction, + sort_lowercase=sort_lowercase, + filters=filters if filters else None, + ) + + return schemas.Page(items=[to_api_snapshot_access_log(item) for item in items], pageToken=cont_token) + + +@router.get("/snapshots") +@no_cache +async def get_snapshots_metadata( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + cursor: Optional[str] = Query(None, description="Continuation token for pagination"), + page_size: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), + sort_by: Optional[SnapshotSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(None, description="Sort direction: 'asc' or 'desc'"), + sort_lowercase: bool = Query(False, description="Use case-insensitive sorting"), + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_created_from: Optional[str] = Query(None, description="Filter results by date"), + filter_created_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.Page[schemas.SnapshotMetadata]: + """ + Get a paginated list of your snapshot metadata. + + Returns metadata for snapshots you own (without content) with support for: + - **Pagination**: Use continuation tokens for large result sets + - **Sorting**: Sort by title, creation date, etc. + - **Filtering**: Filter by title and date ranges + + Snapshots are immutable records that can be shared with others. + They are separate from sessions and are intended for point-in-time captures. + + Note: Consider using `/persistence/snapshot_access_logs` to see both your snapshots and ones shared with you. + """ + snapshot_store = SnapshotStore.create_instance(authenticated_user.get_user_id()) + async with snapshot_store: + filter_factory = FilterFactory(SnapshotDocument) + filters = [] + if filter_title: + filters.append(filter_factory.create("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_created_from: + filters.append(filter_factory.create("metadata.created_at", filter_created_from, "MORE", "_from")) + if filter_created_to: + filters.append(filter_factory.create("metadata.created_at", filter_created_to, "LESS", "_to")) + + items, cont_token = await snapshot_store.get_many_async( + page_token=cursor, + page_size=page_size, + sort_by=sort_by, + sort_direction=sort_direction, + sort_lowercase=sort_lowercase, + filters=filters if filters else None, + ) + return schemas.Page(items=[to_api_snapshot_metadata(item) for item in items], pageToken=cont_token) + + +@router.get("/snapshots/{snapshot_id}") +@no_cache +async def get_snapshot( + snapshot_id: str, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.Snapshot: + """ + Retrieve a complete snapshot by its ID. + + Returns the full snapshot document including: + - Snapshot metadata (title, description, creation date, etc.) + - Complete snapshot content + + **Important**: This endpoint automatically tracks your visit: + - Increments the visit counter + - Updates the "last visited" timestamp + - Creates an access log entry if this is your first visit + + This allows you to see your viewing history in `/persistence/snapshot_access_logs`. + + Any user with the snapshot ID can access snapshots (they are shareable). + """ + snapshot_store = SnapshotStore.create_instance(authenticated_user.get_user_id()) + log_store = SnapshotAccessLogStore.create_instance(user_id=authenticated_user.get_user_id()) + + async with snapshot_store, log_store: + snapshot = await snapshot_store.get_async(snapshot_id) + # Should we clear the log if a snapshot was not found? This could mean that the snapshot was + # deleted but deletion of logs has failed + await log_store.log_snapshot_visit_async(snapshot_id, snapshot.owner_id) + return to_api_snapshot(snapshot) + + +@router.post("/snapshots") +async def create_snapshot( + snapshot: schemas.NewSnapshot, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> str: + """ + Create a new snapshot for point-in-time capture. + + Provide: + - **title**: Snapshot title (required) + - **description**: Optional description + - **content**: Snapshot content (required) + + The system automatically: + - Generates a unique snapshot ID + - Records creation timestamp + - Calculates content hash for integrity + - **Logs an implicit visit** (so it appears in your visited snapshots) + + Snapshots are immutable and can be shared with others via their ID. + + Returns the ID of the newly created snapshot. + """ + snapshot_access = SnapshotStore.create_instance(authenticated_user.get_user_id()) + log_store = SnapshotAccessLogStore.create_instance(authenticated_user.get_user_id()) + + async with snapshot_access, log_store: + snapshot_id = await snapshot_access.create_async( + title=snapshot.title, description=snapshot.description, content=snapshot.content + ) + + # We count snapshot creation as implicit visit. This also makes it so we can get recently created ones alongside other shared screenshots + await log_store.log_snapshot_visit_async( + snapshot_id=snapshot_id, snapshot_owner_id=authenticated_user.get_user_id() + ) + return snapshot_id + + +@router.delete("/snapshots/{snapshot_id}") +async def delete_snapshot( + snapshot_id: str, + background_tasks: BackgroundTasks, + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> None: + """ + Permanently delete a snapshot. + + This operation: + - Removes the snapshot document from the database + - Marks all access logs as deleted (background task) + - Cannot be undone + - Requires ownership verification + + **Background Processing:** + Access logs are marked as deleted asynchronously to avoid blocking the response. + This typically completes within seconds for snapshots with <150 visitor logs. + + Only the snapshot owner can delete their snapshots. + """ + snapshot_store = SnapshotStore.create_instance(authenticated_user.get_user_id()) + async with snapshot_store: + await snapshot_store.delete_async(snapshot_id) + + # This is the fastest solution for the moment. As we are expecting <= 150 logs per snapshot + # and consistency is not critical, we can afford to do this in the background and without + # a safety net. We can later consider adding this to a queue for better reliability. + background_tasks.add_task(mark_logs_deleted_task, snapshot_id=snapshot_id) diff --git a/backend_py/primary/primary/routers/persistence/schemas.py b/backend_py/primary/primary/routers/persistence/schemas.py new file mode 100644 index 000000000..8483a6695 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/schemas.py @@ -0,0 +1,72 @@ +from typing import Generic, List, Optional, TypeVar +from pydantic import BaseModel + + +# Type variable for the generic item type +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: List[T] + pageToken: Optional[str] = None + + +class SessionMetadata(BaseModel): + id: str + ownerId: str + + title: str + description: Optional[str] + + createdAt: str + updatedAt: str + version: int + contentHash: str + + +class Session(BaseModel): + metadata: SessionMetadata + content: str + + +class SnapshotMetadata(BaseModel): + id: str + ownerId: str + title: str + description: Optional[str] + createdAt: str + contentHash: str + + +class Snapshot(BaseModel): + metadata: SnapshotMetadata + content: str + + +class SnapshotAccessLog(BaseModel): + visitorId: str + snapshotId: str + visits: int + firstVisitedAt: str | None + lastVisitedAt: str | None + snapshotDeleted: bool + + snapshotMetadata: SnapshotMetadata + + +class NewSession(BaseModel): + title: str + description: Optional[str] + content: str + + +class SessionUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + content: Optional[str] = None + + +class NewSnapshot(BaseModel): + title: str + description: Optional[str] + content: str diff --git a/backend_py/primary/primary/services/service_exceptions.py b/backend_py/primary/primary/services/service_exceptions.py index 673d1d73f..aad95adee 100644 --- a/backend_py/primary/primary/services/service_exceptions.py +++ b/backend_py/primary/primary/services/service_exceptions.py @@ -8,6 +8,7 @@ class Service(str, Enum): VDS = "vds" USER_SESSION = "user_session" SSDL = "ssdl" + DATABASE = "database" class ServiceLayerException(Exception): diff --git a/backend_py/primary/pyproject.toml b/backend_py/primary/pyproject.toml index dcc78ed0a..886468ca4 100644 --- a/backend_py/primary/pyproject.toml +++ b/backend_py/primary/pyproject.toml @@ -33,6 +33,9 @@ pottery = "^3.0.0" xtgeo = "^4.11.0" core_utils = { path = "../libs/core_utils", develop = true } server_schemas = { path = "../libs/server_schemas", develop = true } +azure-cosmos = "^4.9.0" +aiohttp = "^3.11.18" +nanoid = "^2.0.0" # Special cases diff --git a/docker-compose.yml b/docker-compose.yml index faabb1500..df3df6841 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: - ./frontend/index.html:/usr/src/app/frontend/index.html backend-primary: + depends_on: + cosmos-db-emulator: + condition: service_healthy build: context: . dockerfile: ./backend_py/primary/Dockerfile @@ -31,6 +34,7 @@ services: - WEBVIZ_SSDL_RESOURCE_SCOPE - WEBVIZ_SUMO_ENV - WEBVIZ_VDS_HOST_ADDRESS + - WEBVIZ_DB_CONNECTION_STRING - APPLICATIONINSIGHTS_CONNECTION_STRING - OTEL_RESOURCE_ATTRIBUTES=service.name=primary-backend, service.namespace=local - CODESPACE_NAME # Automatically set env. variable by GitHub codespace @@ -95,3 +99,26 @@ services: - ALLOW_EMPTY_PASSWORD=yes # https://redis.io/docs/management/config/#configuring-redis-as-a-cache - REDIS_EXTRA_FLAGS=--maxmemory 1gb --maxmemory-policy allkeys-lru --save '' --appendonly no --loglevel notice + + cosmos-db-emulator: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + platform: linux/amd64 + tty: true + mem_limit: 4GB + ports: + - "8081:8081" # HTTPS endpoint + - "10250-10255:10250-10255" # Internal emulator ports (needed) + environment: + - AZURE_COSMOS_EMULATOR_PARTITION_COUNT=1 + - AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true + healthcheck: + test: curl -k https://127.0.0.1:8081/_explorer/emulator.pem || exit 1 + interval: 10s + retries: 12 # 2 minutes + start_period: 30s + timeout: 5s + volumes: + - cosmosdrive:/var/lib/cosmosdb/emulator + +volumes: + cosmosdrive: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b971b35e8..824a300b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-plotly.js": "^2.6.0", + "react-toastify": "^11.0.5", "simplify-js": "^1.2.4", "ts-key-enum": "^2.0.13", "uuid": "^9.0.0", @@ -8049,8 +8050,9 @@ "license": "MIT" }, "node_modules/clsx": { - "version": "2.0.0", - "license": "MIT", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -15088,6 +15090,18 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/react-tooltip": { "version": "5.28.0", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 607a2e996..48a65c4c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,10 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --host", - "build": "tsc && NODE_OPTIONS='--max-old-space-size=8192' vite build", + "dev": "npm run generate:module-states-map && vite --host", + "build": "npm run generate:module-states-map && tsc && NODE_OPTIONS='--max-old-space-size=8192' vite build", "preview": "vite preview", - "generate-api": "openapi-ts && node scripts/add-api-suffix.cjs --suffix '_api' --dir src/api/autogen --exportTanstackQueryFromIndex", + "generate:api": "openapi-ts && node scripts/add-api-suffix.cjs --suffix '_api' --dir src/api/autogen --exportTanstackQueryFromIndex", + "generate:module-states-map": "node scripts/generate-module-states-map.js", "typecheck": "tsc --noEmit", "lint": "eslint 'src/**/*.+(ts|tsx|js|jsx|json)' 'tests/**/*.+(ts|tsx|js|jsx|json)' --max-warnings=0", "lint:fix": "eslint 'src/**/*.+(ts|tsx|js|jsx|json)' 'tests/**/*.+(ts|tsx|js|jsx|json)' --fix", @@ -50,6 +51,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-plotly.js": "^2.6.0", + "react-toastify": "^11.0.5", "simplify-js": "^1.2.4", "ts-key-enum": "^2.0.13", "uuid": "^9.0.0", diff --git a/frontend/scripts/generate-module-states-map.js b/frontend/scripts/generate-module-states-map.js new file mode 100644 index 000000000..b2b924af7 --- /dev/null +++ b/frontend/scripts/generate-module-states-map.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import glob from "fast-glob"; + +function sanitize(name) { + const sanitized = name.replace(/[^a-zA-Z0-9]/g, ""); + return /^[0-9]/.test(sanitized) ? `M_${sanitized}` : sanitized; +} + +export async function generateModuleSerializedStateMap() { + const settingsFiles = await glob("src/modules/[^_]*/settings/persistence.ts", { cwd: process.cwd() }); + const viewFiles = await glob("src/modules/[^_]*/view/persistence.ts", { cwd: process.cwd() }); + + const outputLines = []; + const viewMap = new Map(); + const settingsMap = new Map(); + const modules = new Set(); + + const moduleFolders = await glob("src/modules/!(_*)/", { onlyDirectories: true, cwd: process.cwd() }); + + for (const folder of moduleFolders) { + const match = folder.match(/src\/modules\/([^/]+)(\/)?$/); + if (!match) continue; + + const moduleName = match[1]; + modules.add(moduleName); + } + + for (const file of settingsFiles) { + const match = file.match(/src\/modules\/([^/]+)\/settings\/persistence\.ts$/); + if (!match) continue; + + const moduleName = match[1]; + const alias = `${sanitize(moduleName)}Settings`; + settingsMap.set(moduleName, alias); + + outputLines.push( + `import type { SerializedSettings as ${alias} } from "@modules/${moduleName}/settings/persistence";`, + ); + } + + for (const file of viewFiles) { + const match = file.match(/src\/modules\/([^/]+)\/view\/persistence\.ts$/); + if (!match) continue; + + const moduleName = match[1]; + const alias = `${sanitize(moduleName)}View`; + viewMap.set(moduleName, alias); + + outputLines.push(`import type { SerializedView as ${alias} } from "@modules/${moduleName}/view/persistence";`); + } + + console.log("🧪 Matched modules:", Array.from(modules)); + + console.log("🧪 Found settings files:", settingsFiles); + console.log("🧪 Found view files:", viewFiles); + + const sortedModules = Array.from(modules).sort(); + const mapEntries = []; + + for (const moduleName of sortedModules) { + const settingsAlias = settingsMap.get(moduleName) ? `${sanitize(moduleName)}Settings` : "never"; + const viewAlias = viewMap.get(moduleName) ? `${sanitize(moduleName)}View` : "never"; + + mapEntries.push( + ` "${moduleName}": {\n` + + ` settings?: ${settingsAlias === "never" ? "never" : `Partial<${settingsAlias}>`},\n` + + ` view?: ${viewAlias === "never" ? "never" : `Partial<${viewAlias}>`},\n` + + ` },`, + ); + } + + const fileContent = + `// AUTO-GENERATED FILE. DO NOT EDIT.\n` + + `// Generated by generateModuleSerializedStateMap CLI\n\n` + + `${outputLines.join("\n")}\n\n` + + `export type ModuleSerializedStateMap = {\n${mapEntries.join("\n")}\n};\n`; + + const outPath = path.resolve("src/modules/ModuleSerializedStateMap.ts"); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, fileContent + "\n", "utf-8"); + + console.log(`[CLI] ✅ ModuleSerializedStateMap written to ${outPath}`); +} + +generateModuleSerializedStateMap(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0548ab093..646843a35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,14 @@ import { AuthenticationBoundary } from "@framework/internal/components/AuthenticationBoundary"; +import { GlobalConfirmationDialog } from "@framework/internal/components/GlobalConfirmationDialog"; +import { WorkbenchWrapper } from "@framework/internal/components/WorkbenchWrapper/workbenchWrapper"; + import "./modules/registerAllModules"; import "./templates/registerAllTemplates"; -import { WorkbenchWrapper } from "./framework/internal/components/WorkbenchWrapper/workbenchWrapper"; - function App() { return (
+ diff --git a/frontend/src/api/autogen/@tanstack/react-query.gen.ts b/frontend/src/api/autogen/@tanstack/react-query.gen.ts index 3c55f7775..1708a6e12 100644 --- a/frontend/src/api/autogen/@tanstack/react-query.gen.ts +++ b/frontend/src/api/autogen/@tanstack/react-query.gen.ts @@ -1,7 +1,13 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options } from "@hey-api/client-axios"; -import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; +import { + queryOptions, + type UseMutationOptions, + infiniteQueryOptions, + type InfiniteData, + type DefaultError, +} from "@tanstack/react-query"; import type { GetFieldsData_api, GetCasesData_api, @@ -83,6 +89,31 @@ import type { GetRealizationDataData_api, GetVfpTableNamesData_api, GetVfpTableData_api, + GetSessionsMetadataData_api, + GetSessionsMetadataError_api, + GetSessionsMetadataResponse_api, + CreateSessionData_api, + CreateSessionError_api, + CreateSessionResponse_api, + DeleteSessionData_api, + DeleteSessionError_api, + GetSessionData_api, + UpdateSessionData_api, + UpdateSessionError_api, + UpdateSessionResponse_api, + GetSessionMetadataData_api, + GetSnapshotAccessLogsData_api, + GetSnapshotAccessLogsError_api, + GetSnapshotAccessLogsResponse_api, + GetSnapshotsMetadataData_api, + GetSnapshotsMetadataError_api, + GetSnapshotsMetadataResponse_api, + CreateSnapshotData_api, + CreateSnapshotError_api, + CreateSnapshotResponse_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, LoginRouteData_api, AuthorizedCallbackRouteData_api, GetAliveData_api, @@ -159,6 +190,17 @@ import { getRealizationData, getVfpTableNames, getVfpTable, + getSessionsMetadata, + createSession, + deleteSession, + getSession, + updateSession, + getSessionMetadata, + getSnapshotAccessLogs, + getSnapshotsMetadata, + createSnapshot, + deleteSnapshot, + getSnapshot, loginRoute, authorizedCallbackRoute, getAlive, @@ -1592,6 +1634,388 @@ export const getVfpTableOptions = (options: Options) => { }); }; +export const getSessionsMetadataQueryKey = (options?: Options) => [ + createQueryKey("getSessionsMetadata", options), +]; + +export const getSessionsMetadataOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSessionsMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionsMetadataQueryKey(options), + }); +}; + +const createInfiniteParams = [0], "body" | "headers" | "path" | "query">>( + queryKey: QueryKey, + page: K, +) => { + const params = queryKey[0]; + if (page.body) { + params.body = { + ...(queryKey[0].body as any), + ...(page.body as any), + }; + } + if (page.headers) { + params.headers = { + ...queryKey[0].headers, + ...page.headers, + }; + } + if (page.path) { + params.path = { + ...(queryKey[0].path as any), + ...(page.path as any), + }; + } + if (page.query) { + params.query = { + ...(queryKey[0].query as any), + ...(page.query as any), + }; + } + return params as unknown as typeof page; +}; + +export const getSessionsMetadataInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getSessionsMetadata", options, true)]; + +export const getSessionsMetadataInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetSessionsMetadataResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + string | null | Pick>[0], "body" | "headers" | "path" | "query"> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], "body" | "headers" | "path" | "query"> = + typeof pageParam === "object" + ? pageParam + : { + query: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSessionsMetadata({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionsMetadataInfiniteQueryKey(options), + }, + ); +}; + +export const createSessionQueryKey = (options: Options) => [ + createQueryKey("createSession", options), +]; + +export const createSessionOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createSession({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createSessionQueryKey(options), + }); +}; + +export const createSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions< + CreateSessionResponse_api, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await deleteSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSessionQueryKey = (options: Options) => [createQueryKey("getSession", options)]; + +export const getSessionOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSession({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionQueryKey(options), + }); +}; + +export const updateSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions< + UpdateSessionResponse_api, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await updateSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSessionMetadataQueryKey = (options: Options) => [ + createQueryKey("getSessionMetadata", options), +]; + +export const getSessionMetadataOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSessionMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionMetadataQueryKey(options), + }); +}; + +export const getSnapshotAccessLogsQueryKey = (options?: Options) => [ + createQueryKey("getSnapshotAccessLogs", options), +]; + +export const getSnapshotAccessLogsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshotAccessLogs({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotAccessLogsQueryKey(options), + }); +}; + +export const getSnapshotAccessLogsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getSnapshotAccessLogs", options, true)]; + +export const getSnapshotAccessLogsInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetSnapshotAccessLogsResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + string | null | Pick>[0], "body" | "headers" | "path" | "query"> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSnapshotAccessLogs({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotAccessLogsInfiniteQueryKey(options), + }, + ); +}; + +export const getSnapshotsMetadataQueryKey = (options?: Options) => [ + createQueryKey("getSnapshotsMetadata", options), +]; + +export const getSnapshotsMetadataOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshotsMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotsMetadataQueryKey(options), + }); +}; + +export const getSnapshotsMetadataInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getSnapshotsMetadata", options, true)]; + +export const getSnapshotsMetadataInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetSnapshotsMetadataResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + string | null | Pick>[0], "body" | "headers" | "path" | "query"> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick< + QueryKey>[0], + "body" | "headers" | "path" | "query" + > = + typeof pageParam === "object" + ? pageParam + : { + query: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSnapshotsMetadata({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotsMetadataInfiniteQueryKey(options), + }, + ); +}; + +export const createSnapshotQueryKey = (options: Options) => [ + createQueryKey("createSnapshot", options), +]; + +export const createSnapshotOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createSnapshotQueryKey(options), + }); +}; + +export const createSnapshotMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions< + CreateSnapshotResponse_api, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteSnapshotMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await deleteSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSnapshotQueryKey = (options: Options) => [createQueryKey("getSnapshot", options)]; + +export const getSnapshotOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotQueryKey(options), + }); +}; + export const loginRouteQueryKey = (options?: Options) => [createQueryKey("loginRoute", options)]; export const loginRouteOptions = (options?: Options) => { diff --git a/frontend/src/api/autogen/sdk.gen.ts b/frontend/src/api/autogen/sdk.gen.ts index 5d7dd1d9c..cf7d4d2b3 100644 --- a/frontend/src/api/autogen/sdk.gen.ts +++ b/frontend/src/api/autogen/sdk.gen.ts @@ -199,6 +199,37 @@ import type { GetVfpTableData_api, GetVfpTableResponse_api, GetVfpTableError_api, + GetSessionsMetadataData_api, + GetSessionsMetadataResponse_api, + GetSessionsMetadataError_api, + CreateSessionData_api, + CreateSessionResponse_api, + CreateSessionError_api, + DeleteSessionData_api, + DeleteSessionError_api, + GetSessionData_api, + GetSessionResponse_api, + GetSessionError_api, + UpdateSessionData_api, + UpdateSessionResponse_api, + UpdateSessionError_api, + GetSessionMetadataData_api, + GetSessionMetadataResponse_api, + GetSessionMetadataError_api, + GetSnapshotAccessLogsData_api, + GetSnapshotAccessLogsResponse_api, + GetSnapshotAccessLogsError_api, + GetSnapshotsMetadataData_api, + GetSnapshotsMetadataResponse_api, + GetSnapshotsMetadataError_api, + CreateSnapshotData_api, + CreateSnapshotResponse_api, + CreateSnapshotError_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, + GetSnapshotResponse_api, + GetSnapshotError_api, LoginRouteData_api, LoginRouteError_api, AuthorizedCallbackRouteData_api, @@ -1249,6 +1280,282 @@ export const getVfpTable = (options: Optio }); }; +/** + * Get Sessions Metadata + * Get a paginated list of session metadata for the authenticated user. + * + * This endpoint returns session metadata (without content) with support for: + * - **Pagination**: Use the continuation token to fetch subsequent pages + * - **Sorting**: Sort by various fields in ascending or descending order + * - **Case-insensitive sorting**: Optional lowercase sorting for text fields + * - **Filtering**: Filter by title and date ranges + * + * The response includes a continuation token for fetching the next page of results. + */ +export const getSessionsMetadata = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/sessions", + }); +}; + +/** + * Create Session + * Create a new session for the authenticated user. + * + * Provide: + * - **title**: Session title (required) + * - **description**: Optional description + * - **content**: Session content (required) + * + * The system automatically generates: + * - Unique session ID + * - Creation and update timestamps + * - Version number (starts at 1) + * - Content hash for integrity checking + * + * Returns the ID of the newly created session. + */ +export const createSession = ( + options: Options, +) => { + return (options?.client ?? client).post({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/persistence/sessions", + }); +}; + +/** + * Delete Session + * Permanently delete a session. + * + * This operation: + * - Removes the session document from the database + * - Cannot be undone + * - Requires ownership verification + * + * Only the session owner can delete their sessions. + */ +export const deleteSession = ( + options: Options, +) => { + return (options?.client ?? client).delete({ + ...options, + url: "/persistence/sessions/{session_id}", + }); +}; + +/** + * Get Session + * Retrieve a complete session by its ID. + * + * Returns the full session document including: + * - Session metadata (title, description, timestamps, version, etc.) + * - Complete session content + * + * Only the session owner can access this endpoint. + */ +export const getSession = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/sessions/{session_id}", + }); +}; + +/** + * Update Session + * Update an existing session with partial or complete changes. + * + * You can update any combination of: + * - **title**: New session title + * - **description**: New description + * - **content**: New session content + * + * All fields are optional - only provided fields will be updated. + * + * The system automatically: + * - Updates the `updated_at` timestamp + * - Increments the version number + * - Recalculates the content hash if content changed + * - Preserves ownership and creation metadata + * + * Returns the complete updated session. + * + * Only the session owner can update their sessions. + */ +export const updateSession = ( + options: Options, +) => { + return (options?.client ?? client).put({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/persistence/sessions/{session_id}", + }); +}; + +/** + * Get Session Metadata + * Retrieve only the metadata for a specific session. + * + * Returns session metadata without the content, useful for: + * - Listing sessions with details + * - Checking version or timestamps + * - Lightweight operations that don't need full content + * + * Only the session owner can access this endpoint. + */ +export const getSessionMetadata = ( + options: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/sessions/metadata/{session_id}", + }); +}; + +/** + * Get Snapshot Access Logs + * Get a list of all snapshots you have visited. + * + * This endpoint tracks your interaction history with snapshots, including: + * - Snapshots you've created (counted as implicit visits) + * - Snapshots you've viewed + * - Snapshots shared with you that you've accessed + * + * Each access log entry includes: + * - **Visit count**: Number of times you've viewed the snapshot + * - **First visited**: Timestamp of your first visit + * - **Last visited**: Timestamp of your most recent visit + * - **Snapshot metadata**: Title, description, creation date + * - **Deletion status**: Whether the snapshot has been deleted + * + * Supports pagination, sorting, and filtering by: + * - Title (case insensitive) + * - Creation date range + * - Last visited date range + */ +export const getSnapshotAccessLogs = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/snapshot_access_logs", + }); +}; + +/** + * Get Snapshots Metadata + * Get a paginated list of your snapshot metadata. + * + * Returns metadata for snapshots you own (without content) with support for: + * - **Pagination**: Use continuation tokens for large result sets + * - **Sorting**: Sort by title, creation date, etc. + * - **Filtering**: Filter by title and date ranges + * + * Snapshots are immutable records that can be shared with others. + * They are separate from sessions and are intended for point-in-time captures. + * + * Note: Consider using `/persistence/snapshot_access_logs` to see both your snapshots and ones shared with you. + */ +export const getSnapshotsMetadata = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/snapshots", + }); +}; + +/** + * Create Snapshot + * Create a new snapshot for point-in-time capture. + * + * Provide: + * - **title**: Snapshot title (required) + * - **description**: Optional description + * - **content**: Snapshot content (required) + * + * The system automatically: + * - Generates a unique snapshot ID + * - Records creation timestamp + * - Calculates content hash for integrity + * - **Logs an implicit visit** (so it appears in your visited snapshots) + * + * Snapshots are immutable and can be shared with others via their ID. + * + * Returns the ID of the newly created snapshot. + */ +export const createSnapshot = ( + options: Options, +) => { + return (options?.client ?? client).post({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/persistence/snapshots", + }); +}; + +/** + * Delete Snapshot + * Permanently delete a snapshot. + * + * This operation: + * - Removes the snapshot document from the database + * - Marks all access logs as deleted (background task) + * - Cannot be undone + * - Requires ownership verification + * + * **Background Processing:** + * Access logs are marked as deleted asynchronously to avoid blocking the response. + * This typically completes within seconds for snapshots with <150 visitor logs. + * + * Only the snapshot owner can delete their snapshots. + */ +export const deleteSnapshot = ( + options: Options, +) => { + return (options?.client ?? client).delete({ + ...options, + url: "/persistence/snapshots/{snapshot_id}", + }); +}; + +/** + * Get Snapshot + * Retrieve a complete snapshot by its ID. + * + * Returns the full snapshot document including: + * - Snapshot metadata (title, description, creation date, etc.) + * - Complete snapshot content + * + * **Important**: This endpoint automatically tracks your visit: + * - Increments the visit counter + * - Updates the "last visited" timestamp + * - Creates an access log entry if this is your first visit + * + * This allows you to see your viewing history in `/persistence/snapshot_access_logs`. + * + * Any user with the snapshot ID can access snapshots (they are shareable). + */ +export const getSnapshot = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: "/persistence/snapshots/{snapshot_id}", + }); +}; + /** * Login Route */ diff --git a/frontend/src/api/autogen/types.gen.ts b/frontend/src/api/autogen/types.gen.ts index 068449c69..1aa805206 100644 --- a/frontend/src/api/autogen/types.gen.ts +++ b/frontend/src/api/autogen/types.gen.ts @@ -416,6 +416,18 @@ export type NetworkNode_api = { children: Array; }; +export type NewSession_api = { + title: string; + description: string | null; + content: string; +}; + +export type NewSnapshot_api = { + title: string; + description: string | null; + content: string; +}; + export enum NodeType_api { PROD = "prod", INJ = "inj", @@ -430,6 +442,21 @@ export type Observations_api = { rft?: Array; }; +export type PageSessionMetadata_api = { + items: Array; + pageToken?: string | null; +}; + +export type PageSnapshotAccessLog_api = { + items: Array; + pageToken?: string | null; +}; + +export type PageSnapshotMetadata_api = { + items: Array; + pageToken?: string | null; +}; + export type PointSetXy_api = { x_points: Array; y_points: Array; @@ -702,6 +729,78 @@ export enum SensitivityType_api { SCENARIO = "scenario", } +export type Session_api = { + metadata: SessionMetadata_api; + content: string; +}; + +export type SessionMetadata_api = { + id: string; + ownerId: string; + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + version: number; + contentHash: string; +}; + +export enum SessionSortBy_api { + METADATA_CREATED_AT = "metadata.created_at", + METADATA_UPDATED_AT = "metadata.updated_at", + METADATA_TITLE = "metadata.title", +} + +export type SessionUpdate_api = { + title?: string | null; + description?: string | null; + content?: string | null; +}; + +export type Snapshot_api = { + metadata: SnapshotMetadata_api; + content: string; +}; + +export type SnapshotAccessLog_api = { + visitorId: string; + snapshotId: string; + visits: number; + firstVisitedAt: string | null; + lastVisitedAt: string | null; + snapshotDeleted: boolean; + snapshotMetadata: SnapshotMetadata_api; +}; + +export enum SnapshotAccessLogSortBy_api { + VISITS = "visits", + LAST_VISITED_AT = "last_visited_at", + SNAPSHOT_METADATA_TITLE = "snapshot_metadata.title", + SNAPSHOT_METADATA_TITLE_LOWER = "snapshot_metadata.title__lower", + SNAPSHOT_METADATA_CREATED_AT = "snapshot_metadata.created_at", +} + +export type SnapshotMetadata_api = { + id: string; + ownerId: string; + title: string; + description: string | null; + createdAt: string; + contentHash: string; +}; + +export enum SnapshotSortBy_api { + CREATED_AT = "created_at", + UPDATED_AT = "updated_at", + TITLE = "title", + TITLE_LOWER = "title_lower", +} + +export enum SortDirection_api { + ASC = "asc", + DESC = "desc", +} + export enum StatisticFunction_api { MEAN = "MEAN", MIN = "MIN", @@ -3959,6 +4058,415 @@ export type GetVfpTableResponses_api = { export type GetVfpTableResponse_api = GetVfpTableResponses_api[keyof GetVfpTableResponses_api]; +export type GetSessionsMetadataData_api = { + body?: never; + path?: never; + query?: { + /** + * Continuation token for pagination + */ + cursor?: string | null; + /** + * Field to sort by (e.g., 'metadata.title') + */ + sort_by?: SessionSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Use case-insensitive sorting + */ + sort_lowercase?: boolean; + /** + * Limit the number of results + */ + page_size?: number; + /** + * Filter results by title (case insensitive) + */ + filter_title?: string | null; + /** + * Filter results by date + */ + filter_updated_from?: string | null; + /** + * Filter results by date + */ + filter_updated_to?: string | null; + zCacheBust?: string; + }; + url: "/persistence/sessions"; +}; + +export type GetSessionsMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionsMetadataError_api = GetSessionsMetadataErrors_api[keyof GetSessionsMetadataErrors_api]; + +export type GetSessionsMetadataResponses_api = { + /** + * Successful Response + */ + 200: PageSessionMetadata_api; +}; + +export type GetSessionsMetadataResponse_api = GetSessionsMetadataResponses_api[keyof GetSessionsMetadataResponses_api]; + +export type CreateSessionData_api = { + body: NewSession_api; + path?: never; + query?: { + zCacheBust?: string; + }; + url: "/persistence/sessions"; +}; + +export type CreateSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type CreateSessionError_api = CreateSessionErrors_api[keyof CreateSessionErrors_api]; + +export type CreateSessionResponses_api = { + /** + * Successful Response + */ + 200: string; +}; + +export type CreateSessionResponse_api = CreateSessionResponses_api[keyof CreateSessionResponses_api]; + +export type DeleteSessionData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/sessions/{session_id}"; +}; + +export type DeleteSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type DeleteSessionError_api = DeleteSessionErrors_api[keyof DeleteSessionErrors_api]; + +export type DeleteSessionResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSessionData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/sessions/{session_id}"; +}; + +export type GetSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionError_api = GetSessionErrors_api[keyof GetSessionErrors_api]; + +export type GetSessionResponses_api = { + /** + * Successful Response + */ + 200: Session_api; +}; + +export type GetSessionResponse_api = GetSessionResponses_api[keyof GetSessionResponses_api]; + +export type UpdateSessionData_api = { + body: SessionUpdate_api; + path: { + session_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/sessions/{session_id}"; +}; + +export type UpdateSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type UpdateSessionError_api = UpdateSessionErrors_api[keyof UpdateSessionErrors_api]; + +export type UpdateSessionResponses_api = { + /** + * Successful Response + */ + 200: Session_api; +}; + +export type UpdateSessionResponse_api = UpdateSessionResponses_api[keyof UpdateSessionResponses_api]; + +export type GetSessionMetadataData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/sessions/metadata/{session_id}"; +}; + +export type GetSessionMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionMetadataError_api = GetSessionMetadataErrors_api[keyof GetSessionMetadataErrors_api]; + +export type GetSessionMetadataResponses_api = { + /** + * Successful Response + */ + 200: SessionMetadata_api; +}; + +export type GetSessionMetadataResponse_api = GetSessionMetadataResponses_api[keyof GetSessionMetadataResponses_api]; + +export type GetSnapshotAccessLogsData_api = { + body?: never; + path?: never; + query?: { + /** + * Continuation token for pagination + */ + cursor?: string | null; + /** + * Limit the number of results + */ + page_size?: number | null; + /** + * Sort the result by + */ + sort_by?: SnapshotAccessLogSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Use case-insensitive sorting + */ + sort_lowercase?: boolean; + /** + * Filter results by title (case insensitive) + */ + filter_title?: string | null; + /** + * Filter results by date + */ + filter_created_from?: string | null; + /** + * Filter results by date + */ + filter_created_to?: string | null; + /** + * Filter results by date of last visit + */ + filter_last_visited_from?: string | null; + /** + * Filter results by date of last visit + */ + filter_last_visited_to?: string | null; + zCacheBust?: string; + }; + url: "/persistence/snapshot_access_logs"; +}; + +export type GetSnapshotAccessLogsErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotAccessLogsError_api = GetSnapshotAccessLogsErrors_api[keyof GetSnapshotAccessLogsErrors_api]; + +export type GetSnapshotAccessLogsResponses_api = { + /** + * Successful Response + */ + 200: PageSnapshotAccessLog_api; +}; + +export type GetSnapshotAccessLogsResponse_api = GetSnapshotAccessLogsResponses_api[keyof GetSnapshotAccessLogsResponses_api]; + +export type GetSnapshotsMetadataData_api = { + body?: never; + path?: never; + query?: { + /** + * Continuation token for pagination + */ + cursor?: string | null; + /** + * Limit the number of results + */ + page_size?: number | null; + /** + * Sort the result by + */ + sort_by?: SnapshotSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Use case-insensitive sorting + */ + sort_lowercase?: boolean; + /** + * Filter results by title (case insensitive) + */ + filter_title?: string | null; + /** + * Filter results by date + */ + filter_created_from?: string | null; + /** + * Filter results by date + */ + filter_created_to?: string | null; + zCacheBust?: string; + }; + url: "/persistence/snapshots"; +}; + +export type GetSnapshotsMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotsMetadataError_api = GetSnapshotsMetadataErrors_api[keyof GetSnapshotsMetadataErrors_api]; + +export type GetSnapshotsMetadataResponses_api = { + /** + * Successful Response + */ + 200: PageSnapshotMetadata_api; +}; + +export type GetSnapshotsMetadataResponse_api = GetSnapshotsMetadataResponses_api[keyof GetSnapshotsMetadataResponses_api]; + +export type CreateSnapshotData_api = { + body: NewSnapshot_api; + path?: never; + query?: { + zCacheBust?: string; + }; + url: "/persistence/snapshots"; +}; + +export type CreateSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type CreateSnapshotError_api = CreateSnapshotErrors_api[keyof CreateSnapshotErrors_api]; + +export type CreateSnapshotResponses_api = { + /** + * Successful Response + */ + 200: string; +}; + +export type CreateSnapshotResponse_api = CreateSnapshotResponses_api[keyof CreateSnapshotResponses_api]; + +export type DeleteSnapshotData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/snapshots/{snapshot_id}"; +}; + +export type DeleteSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type DeleteSnapshotError_api = DeleteSnapshotErrors_api[keyof DeleteSnapshotErrors_api]; + +export type DeleteSnapshotResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSnapshotData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + zCacheBust?: string; + }; + url: "/persistence/snapshots/{snapshot_id}"; +}; + +export type GetSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotError_api = GetSnapshotErrors_api[keyof GetSnapshotErrors_api]; + +export type GetSnapshotResponses_api = { + /** + * Successful Response + */ + 200: Snapshot_api; +}; + +export type GetSnapshotResponse_api = GetSnapshotResponses_api[keyof GetSnapshotResponses_api]; + export type LoginRouteData_api = { body?: never; path?: never; diff --git a/frontend/src/framework/ConfirmationService.ts b/frontend/src/framework/ConfirmationService.ts new file mode 100644 index 000000000..72e278bbb --- /dev/null +++ b/frontend/src/framework/ConfirmationService.ts @@ -0,0 +1,41 @@ +import type { ButtonProps } from "@lib/components/Button/button"; + +export type ConfirmAction = { + id: T; + label: string; + color?: ButtonProps["color"]; +}; + +export type ConfirmOptions = { + title: string; + message: string; + actions: ConfirmAction[]; +}; + +class ConfirmationServiceImpl { + private _resolver: ((result: T) => void) | null = null; + private _showDialogCallback: ((options: ConfirmOptions) => void) | null = null; + + setShowDialogCallback(callback: (options: ConfirmOptions) => void) { + this._showDialogCallback = callback; + } + + confirm(options: ConfirmOptions): Promise { + if (!this._showDialogCallback) { + throw new Error("ConfirmationService: Show dialog callback is not set."); + } + + return new Promise((resolve) => { + this._resolver = resolve; + this._showDialogCallback!(options); + }); + } + + resolve(result: T): void { + this._resolver?.(result); + this._resolver = null; // Reset resolver after resolving + } +} + +// Making a singleton instance of ConfirmationService +export const ConfirmationService = new ConfirmationServiceImpl(); diff --git a/frontend/src/framework/GuiMessageBroker.ts b/frontend/src/framework/GuiMessageBroker.ts index be7a30e6e..dfd26e481 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -30,8 +30,18 @@ export enum GuiState { AppInitialized = "appInitialized", NumberOfUnsavedRealizationFilters = "numberOfUnsavedRealizationFilters", NumberOfEffectiveRealizationFilters = "numberOfEffectiveRealizationFilters", + SaveSessionDialogOpen = "saveSessionDialogOpen", + SessionHasUnsavedChanges = "sessionHasUnsavedChanges", + IsSavingSession = "isSavingSession", IsLoadingSession = "isLoadingSession", + IsMakingSnapshot = "isMakingSnapshot", EnsembleDialogOpen = "ensembleDialogOpen", + MultiSessionsRecoveryDialogOpen = "multiSessionsRecoveryDialogOpen", + ActiveSessionRecoveryDialogOpen = "activeSessionRecoveryDialogOpen", + MakeSnapshotDialogOpen = "makeSnapshotDialogOpen", + TemplatesDialogOpen = "templatesDialogOpen", + SessionSnapshotOverviewDialogOpen = "sessionSnapshotOverviewDialogOpen", + SessionSnapshotOverviewDialogMode = "sessionSnapshotOverviewDialogMode", } export enum GuiEvent { @@ -98,7 +108,17 @@ type GuiStateValueTypes = { [GuiState.NumberOfUnsavedRealizationFilters]: number; [GuiState.NumberOfEffectiveRealizationFilters]: number; [GuiState.IsLoadingSession]: boolean; + [GuiState.IsSavingSession]: boolean; [GuiState.EnsembleDialogOpen]: boolean; + [GuiState.MultiSessionsRecoveryDialogOpen]: boolean; + [GuiState.ActiveSessionRecoveryDialogOpen]: boolean; + [GuiState.MakeSnapshotDialogOpen]: boolean; + [GuiState.IsMakingSnapshot]: boolean; + [GuiState.SaveSessionDialogOpen]: boolean; + [GuiState.SessionHasUnsavedChanges]: boolean; + [GuiState.TemplatesDialogOpen]: boolean; + [GuiState.SessionSnapshotOverviewDialogOpen]: boolean; + [GuiState.SessionSnapshotOverviewDialogMode]: "sessions" | "snapshots"; }; const defaultStates: Map = new Map(); @@ -112,8 +132,17 @@ defaultStates.set(GuiState.AppInitialized, false); defaultStates.set(GuiState.NumberOfUnsavedRealizationFilters, 0); defaultStates.set(GuiState.NumberOfEffectiveRealizationFilters, 0); defaultStates.set(GuiState.IsLoadingSession, false); +defaultStates.set(GuiState.IsSavingSession, false); defaultStates.set(GuiState.EditDataChannelConnections, false); defaultStates.set(GuiState.EnsembleDialogOpen, false); +defaultStates.set(GuiState.MultiSessionsRecoveryDialogOpen, false); +defaultStates.set(GuiState.ActiveSessionRecoveryDialogOpen, false); +defaultStates.set(GuiState.MakeSnapshotDialogOpen, false); +defaultStates.set(GuiState.IsMakingSnapshot, false); +defaultStates.set(GuiState.SessionHasUnsavedChanges, false); +defaultStates.set(GuiState.TemplatesDialogOpen, false); +defaultStates.set(GuiState.SessionSnapshotOverviewDialogOpen, false); +defaultStates.set(GuiState.SessionSnapshotOverviewDialogMode, "sessions"); const persistentStates: GuiState[] = [ GuiState.LeftSettingsPanelWidthInPercent, diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index b03c6ae12..87250df77 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -1,7 +1,9 @@ import type React from "react"; +import type { JTDSchemaType } from "ajv/dist/core"; import type { Getter, Setter } from "jotai"; +import type { AtomStoreMaster } from "./AtomStoreMaster"; import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; import type { InitialSettings } from "./InitialSettings"; import type { SettingsContext, ViewContext } from "./ModuleContext"; @@ -76,6 +78,63 @@ export type InterfaceEffects = (( getAtomValue: Getter, ) => void)[]; +export type JTDBaseType = Record; + +export type ModuleComponentsStateBase = { + settings?: Record; + view?: Record; +}; + +export type SerializedModuleComponentsState = { + settings: TSerializedStateDef["settings"]; + view: TSerializedStateDef["view"]; +}; + +export type NoModuleStateSchema = { + settings: Record; + view: Record; +}; + +export type ModuleStateSchema = { + settings?: JTDSchemaType; + view?: JTDSchemaType; +}; + +export interface SerializeStateFunction { + (get: Getter): T; +} + +export interface DeserializeStateFunction { + (raw: Partial, set: Setter): void; +} + +export type ModuleComponentSerializationFunctions = + TSerializedStateDef extends NoModuleStateSchema + ? { + serializeStateFunctions?: never; + deserializeStateFunctions?: never; + } + : { + serializeStateFunctions: { + settings?: SerializeStateFunction; + view?: SerializeStateFunction; + }; + deserializeStateFunctions: { + settings?: DeserializeStateFunction; + view?: DeserializeStateFunction; + }; + }; + +export function hasSerialization( + val: ModuleComponentSerializationFunctions, +): val is Exclude { + return !!(val as any).serializeStateFunctions; +} + +export type MakeReadonly = { + readonly [P in keyof T]: T[P]; +}; + export type ModuleSettings< TInterfaceTypes extends ModuleInterfaceTypes = { settingsToView: Record; @@ -97,7 +156,7 @@ export enum ImportStatus { Failed = "Failed", } -export interface ModuleOptions { +export type ModuleOptions = { name: string; defaultTitle: string; category: ModuleCategory; @@ -109,15 +168,16 @@ export interface ModuleOptions { channelDefinitions?: ChannelDefinition[]; channelReceiverDefinitions?: ChannelReceiverDefinition[]; onInstanceUnloadFunc?: OnInstanceUnloadFunc; -} + serializedStateSchema?: ModuleStateSchema; +}; -export class Module { +export class Module { private _name: string; private _defaultTitle: string; public viewFC: ModuleView; public settingsFC: ModuleSettings; protected _importState: ImportStatus = ImportStatus.NotImported; - private _moduleInstances: ModuleInstance[] = []; + private _moduleInstances: ModuleInstance[] = []; private _settingsToViewInterfaceInitialization: InterfaceInitialization< Exclude > | null = null; @@ -137,8 +197,11 @@ export class Module { private _category: ModuleCategory; private _devState: ModuleDevState; private _dataTagIds: ModuleDataTagId[]; + private _serializedStateSchema: ModuleStateSchema | null; + private _serializationFunctions: ModuleComponentSerializationFunctions | undefined; + private _atomStoreMaster: AtomStoreMaster | null = null; - constructor(options: ModuleOptions) { + constructor(options: ModuleOptions) { this._name = options.name; this._defaultTitle = options.defaultTitle; this._category = options.category; @@ -152,6 +215,11 @@ export class Module { this._channelDefinitions = options.channelDefinitions ?? null; this._channelReceiverDefinitions = options.channelReceiverDefinitions ?? null; this._dataTagIds = options.dataTagIds ?? []; + this._serializedStateSchema = options.serializedStateSchema ?? null; + } + + getSerializedStateSchema(): ModuleStateSchema | null { + return this._serializedStateSchema; } getDrawPreviewFunc(): DrawPreviewFunc | null { @@ -210,6 +278,14 @@ export class Module { this._settingsToViewInterfaceEffects = atomsInitialization; } + setSerializationFunctions(serializationFunctions: ModuleComponentSerializationFunctions): void { + this._serializationFunctions = serializationFunctions; + } + + getComponentSerializationFunctions(): ModuleComponentSerializationFunctions | undefined { + return this._serializationFunctions; + } + getViewToSettingsInterfaceEffects(): InterfaceEffects> { return this._viewToSettingsInterfaceEffects; } @@ -226,13 +302,15 @@ export class Module { return this._syncableSettingKeys.includes(key); } - makeInstance(id: string): ModuleInstance { - const instance = new ModuleInstance({ + makeInstance(id: string, atomStoreMaster: AtomStoreMaster): ModuleInstance { + const instance = new ModuleInstance({ module: this, + atomStoreMaster, id, channelDefinitions: this._channelDefinitions, channelReceiverDefinitions: this._channelReceiverDefinitions, }); + atomStoreMaster.makeAtomStoreForModuleInstance(id); this._moduleInstances.push(instance); this.maybeImportSelf(); return instance; @@ -249,8 +327,7 @@ export class Module { }); } - private initializeModuleInstance(instance: ModuleInstance): void { - instance.initialize(); + private initializeModuleInstance(instance: ModuleInstance): void { if (this._settingsToViewInterfaceInitialization) { instance.makeSettingsToViewInterface(this._settingsToViewInterfaceInitialization); } @@ -259,9 +336,13 @@ export class Module { instance.makeViewToSettingsInterface(this._viewToSettingsInterfaceInitialization); } instance.makeViewToSettingsInterfaceEffectsAtom(); + if (this._serializationFunctions) { + instance.makeSerializer(this._serializationFunctions); + } + instance.initialize(); } - private maybeImportSelf(): void { + private async maybeImportSelf(): Promise { if (this._importState !== ImportStatus.NotImported) { if (this._importState === ImportStatus.Imported) { this._moduleInstances.forEach((instance) => { @@ -285,16 +366,15 @@ export class Module { return; } - importer() - .then(() => { - this.setImportState(ImportStatus.Imported); - this._moduleInstances.forEach((instance) => { - this.initializeModuleInstance(instance); - }); - }) - .catch((e) => { - console.error(`Failed to import module ${this._name}`, e); - this.setImportState(ImportStatus.Failed); + try { + await importer(); + this.setImportState(ImportStatus.Imported); + this._moduleInstances.forEach((instance) => { + this.initializeModuleInstance(instance); }); + } catch (e) { + console.error(`Failed to initialize module ${this._name}`, e); + this.setImportState(ImportStatus.Failed); + } } } diff --git a/frontend/src/framework/ModuleContext.ts b/frontend/src/framework/ModuleContext.ts index 1b86e29cf..4315d8041 100644 --- a/frontend/src/framework/ModuleContext.ts +++ b/frontend/src/framework/ModuleContext.ts @@ -22,9 +22,9 @@ import type { InterfaceBaseType } from "./UniDirectionalModuleComponentsInterfac import { useInterfaceValue } from "./UniDirectionalModuleComponentsInterface"; export class ModuleContext { - protected _moduleInstance: ModuleInstance; + protected _moduleInstance: ModuleInstance; - constructor(moduleInstance: ModuleInstance) { + constructor(moduleInstance: ModuleInstance) { this._moduleInstance = moduleInstance; } diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 1b44eabf3..0b643092f 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -5,11 +5,22 @@ import type { Atom } from "jotai"; import { atom } from "jotai"; import { atomEffect } from "jotai-effect"; +import type { AtomStoreMaster } from "./AtomStoreMaster"; import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; import type { InitialSettings } from "./InitialSettings"; -import { ChannelManager } from "./internal/DataChannels/ChannelManager"; +import type { Dashboard } from "./internal/Dashboard"; +import { ChannelManager, type SerializedDataChannelReceiverSubscription } from "./internal/DataChannels/ChannelManager"; +import { ModuleInstanceSerializer } from "./internal/ModuleInstanceSerializer"; import { ModuleInstanceStatusControllerInternal } from "./internal/ModuleInstanceStatusControllerInternal"; -import type { ImportStatus, Module, ModuleInterfaceTypes, ModuleSettings, ModuleView } from "./Module"; +import type { + ImportStatus, + Module, + ModuleInterfaceTypes, + ModuleSettings, + ModuleComponentsStateBase, + ModuleView, + ModuleComponentSerializationFunctions, +} from "./Module"; import { ModuleContext } from "./ModuleContext"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; @@ -27,6 +38,7 @@ export enum ModuleInstanceTopic { SYNCED_SETTINGS = "synced-settings", LIFECYCLE_STATE = "state", IMPORT_STATUS = "import-status", + SERIALIZED_STATE = "serialized-state", } export type ModuleInstanceTopicValueTypes = { @@ -34,40 +46,57 @@ export type ModuleInstanceTopicValueTypes = { [ModuleInstanceTopic.SYNCED_SETTINGS]: SyncSettingKey[]; [ModuleInstanceTopic.LIFECYCLE_STATE]: ModuleInstanceLifeCycleState; [ModuleInstanceTopic.IMPORT_STATUS]: ImportStatus; + [ModuleInstanceTopic.SERIALIZED_STATE]: ModuleComponentsStateBase; }; -export interface ModuleInstanceOptions { - module: Module; +export interface ModuleInstanceOptions< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedState extends ModuleComponentsStateBase, +> { + module: Module; + atomStoreMaster: AtomStoreMaster; id: string; channelDefinitions: ChannelDefinition[] | null; channelReceiverDefinitions: ChannelReceiverDefinition[] | null; } -export type ModuleInstanceFullState = { +type StringifiedSerializedModuleState = { + settings?: string; + view?: string; +}; + +export type PartialSerializedModuleState = { + settings?: Partial; + view?: Partial; +}; + +export type ModuleInstanceSerializedState = { id: string; name: string; - dataChannelReceiverSubscriptions: { - idString: string; - listensToModuleInstanceId: string; - channelIdString: string; - contentIdStrings: string[]; - }[]; + dataChannelReceiverSubscriptions: SerializedDataChannelReceiverSubscription[]; syncedSettingKeys: SyncSettingKey[]; + serializedState: StringifiedSerializedModuleState | null; }; -export class ModuleInstance { +export class ModuleInstance< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleComponentsStateBase, +> { private _id: string; private _title: string; private _initialized: boolean = false; private _moduleInstanceState: ModuleInstanceLifeCycleState = ModuleInstanceLifeCycleState.INITIALIZING; private _fatalError: { err: Error; errInfo: ErrorInfo } | null = null; private _syncedSettingKeys: SyncSettingKey[] = []; - private _module: Module; + private _module: Module; private _context: ModuleContext | null = null; private _subscribers: Map void>> = new Map(); private _initialSettings: InitialSettings | null = null; private _statusController: ModuleInstanceStatusControllerInternal = new ModuleInstanceStatusControllerInternal(); + + // ChannelManager should be elevated to Dashboard level and shared among module instances in the dashboard private _channelManager: ChannelManager; + private _settingsToViewInterface: UniDirectionalModuleComponentsInterface< Exclude > | null = null; @@ -76,11 +105,18 @@ export class ModuleInstance { > | null = null; private _settingsToViewInterfaceEffectsAtom: Atom | null = null; private _viewToSettingsInterfaceEffectsAtom: Atom | null = null; + private _atomStoreMaster: AtomStoreMaster; - constructor(options: ModuleInstanceOptions) { + private _dashboard: Dashboard | null = null; + private _serializer: ModuleInstanceSerializer | null = null; + private _storedSerializedState: ModuleInstanceSerializedState | null = null; + private _storedTemplateState: PartialSerializedModuleState | null = null; + + constructor(options: ModuleInstanceOptions) { this._id = options.id; this._title = options.module.getDefaultTitle(); this._module = options.module; + this._atomStoreMaster = options.atomStoreMaster; this._channelManager = new ChannelManager(this._id); @@ -98,17 +134,11 @@ export class ModuleInstance { } } - setFullState(fullState: ModuleInstanceFullState): void { - this._syncedSettingKeys = fullState.syncedSettingKeys; - - this._id = fullState.id; - this._title = fullState.name; - } - - getFullState(): ModuleInstanceFullState { + serialize(): ModuleInstanceSerializedState { return { id: this._id, name: this._module.getName(), + // Replace with channel manager's own serialization logic dataChannelReceiverSubscriptions: this._channelManager .getReceivers() .filter((receiver) => receiver.hasActiveSubscription()) @@ -119,9 +149,47 @@ export class ModuleInstance { contentIdStrings: receiver.getContentIdStrings(), })), syncedSettingKeys: this._syncedSettingKeys, + serializedState: this._serializer?.getStringifiedSerializedState() ?? null, }; } + initiateDeserialization(raw: ModuleInstanceSerializedState, dashboard: Dashboard): void { + this._storedSerializedState = raw; + this._dashboard = dashboard; + this.deserialize(); + } + + initiateTemplateStateApplication(initialState: PartialSerializedModuleState): void { + this._storedTemplateState = initialState; + this.applyTemplateState(); + } + + private applyTemplateState(): void { + if (this._initialized && this._storedTemplateState && this._serializer) { + this._serializer.applyTemplateState(this._storedTemplateState); + this._storedTemplateState = null; + } + } + + private deserialize(): void { + if (this._initialized && this._storedSerializedState && this._dashboard) { + this._syncedSettingKeys = this._storedSerializedState.syncedSettingKeys; + + this._id = this._storedSerializedState.id; + + if (this._storedSerializedState.serializedState && this._serializer) { + this._serializer.deserializeState(this._storedSerializedState.serializedState); + } + + this._channelManager.deserialize( + this._storedSerializedState.dataChannelReceiverSubscriptions, + this._dashboard.getModuleInstances(), + ); + + this._storedSerializedState = null; + } + } + getUniDirectionalSettingsToViewInterface(): UniDirectionalModuleComponentsInterface< Exclude > { @@ -148,6 +216,24 @@ export class ModuleInstance { this._context = new ModuleContext(this); this._initialized = true; this.setModuleInstanceState(ModuleInstanceLifeCycleState.OK); + this.deserialize(); + this.applyTemplateState(); + } + + makeSerializer(serializationFunctions: ModuleComponentSerializationFunctions | null): void { + if (serializationFunctions) { + this._serializer = new ModuleInstanceSerializer( + this, + this._atomStoreMaster.getAtomStoreForModuleInstance(this._id), + this._module.getSerializedStateSchema(), + serializationFunctions, + this.handleStateChange.bind(this), + ); + } + } + + handleStateChange(): void { + this.notifySubscribers(ModuleInstanceTopic.SERIALIZED_STATE); } makeSettingsToViewInterface( @@ -333,7 +419,7 @@ export class ModuleInstance { return snapshotGetter; } - getModule(): Module { + getModule(): Module { return this._module; } @@ -400,7 +486,7 @@ export class ModuleInstance { } export function useModuleInstanceTopicValue( - moduleInstance: ModuleInstance, + moduleInstance: ModuleInstance, topic: T, ): ModuleInstanceTopicValueTypes[T] { const value = React.useSyncExternalStore( diff --git a/frontend/src/framework/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index f32a717d4..f0475351c 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -5,7 +5,11 @@ import type { ModuleCategory, ModuleDevState, ModuleInterfaceTypes, + ModuleComponentsStateBase, + NoModuleStateSchema, OnInstanceUnloadFunc, + ModuleStateSchema, + ModuleComponentSerializationFunctions, } from "./Module"; import { Module } from "./Module"; import type { ModuleDataTagId } from "./ModuleDataTags"; @@ -13,7 +17,7 @@ import type { DrawPreviewFunc } from "./Preview"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; -export type RegisterModuleOptions = { +export type RegisterModuleOptions = { moduleName: string; category: ModuleCategory; devState: ModuleDevState; @@ -25,7 +29,25 @@ export type RegisterModuleOptions = { preview?: DrawPreviewFunc; description?: string; onInstanceUnload?: OnInstanceUnloadFunc; -}; +} & (TSerializedStateDef extends NoModuleStateSchema + ? { serializedStateSchema?: never } + : { + serializedStateSchema: ModuleStateSchema; + }); + +export type InitModuleOptions< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = { + settingsToViewInterfaceInitialization?: TInterfaceTypes["settingsToView"] extends undefined + ? undefined + : InterfaceInitialization>; + viewToSettingsInterfaceInitialization?: TInterfaceTypes["viewToSettings"] extends undefined + ? undefined + : InterfaceInitialization>; + viewToSettingsInterfaceEffects?: InterfaceEffects>; + settingsToViewInterfaceEffects?: InterfaceEffects>; +} & ModuleComponentSerializationFunctions; export class ModuleNotFoundError extends Error { readonly moduleName: string; @@ -38,15 +60,20 @@ export class ModuleNotFoundError extends Error { } export class ModuleRegistry { - private static _registeredModules: Record> = {}; - private static _moduleNotFoundPlaceholders: Record> = {}; + private static _registeredModules: Record> = {}; + private static _moduleNotFoundPlaceholders: Record> = {}; private constructor() {} - static registerModule( - options: RegisterModuleOptions, - ): Module { - const module = new Module({ + static registerModule< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, + >(options: RegisterModuleOptions): Module { + if (this._registeredModules[options.moduleName]) { + throw new Error(`Module with name '${options.moduleName}' is already registered.`); + } + + const module = new Module({ name: options.moduleName, defaultTitle: options.defaultTitle, category: options.category, @@ -58,24 +85,21 @@ export class ModuleRegistry { drawPreviewFunc: options.preview, onInstanceUnloadFunc: options.onInstanceUnload, description: options.description, + serializedStateSchema: options.serializedStateSchema as unknown as + | ModuleStateSchema + | undefined, }); this._registeredModules[options.moduleName] = module; return module; } - static initModule( + static initModule< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, + >( moduleName: string, - options: { - settingsToViewInterfaceInitialization?: TInterfaceTypes["settingsToView"] extends undefined - ? undefined - : InterfaceInitialization>; - viewToSettingsInterfaceInitialization?: TInterfaceTypes["viewToSettings"] extends undefined - ? undefined - : InterfaceInitialization>; - viewToSettingsInterfaceEffects?: InterfaceEffects>; - settingsToViewInterfaceEffects?: InterfaceEffects>; - }, - ): Module { + options: InitModuleOptions, + ): Module { const module = this._registeredModules[moduleName]; if (module) { if (options.settingsToViewInterfaceInitialization) { @@ -90,25 +114,31 @@ export class ModuleRegistry { if (options.settingsToViewInterfaceEffects) { module.setSettingsToViewInterfaceEffects(options.settingsToViewInterfaceEffects); } - return module as Module; + if (options.serializeStateFunctions && options.deserializeStateFunctions) { + module.setSerializationFunctions({ + serializeStateFunctions: options.serializeStateFunctions, + deserializeStateFunctions: options.deserializeStateFunctions, + }); + } + return module as Module; } throw new ModuleNotFoundError(moduleName); } - static getModule(moduleName: string): Module { + static getModule(moduleName: string): Module { const module = this._registeredModules[moduleName]; if (module) { - return module as Module; + return module as Module; } const placeholder = this._moduleNotFoundPlaceholders[moduleName]; if (placeholder) { - return placeholder as Module; + return placeholder as Module; } this._moduleNotFoundPlaceholders[moduleName] = new ModuleNotFoundPlaceholder(moduleName); - return this._moduleNotFoundPlaceholders[moduleName] as Module; + return this._moduleNotFoundPlaceholders[moduleName] as Module; } - static getRegisteredModules(): Record> { + static getRegisteredModules(): Record> { return this._registeredModules; } } diff --git a/frontend/src/framework/TemplateRegistry.ts b/frontend/src/framework/TemplateRegistry.ts index b70fb1dc8..1c7a90e64 100644 --- a/frontend/src/framework/TemplateRegistry.ts +++ b/frontend/src/framework/TemplateRegistry.ts @@ -1,3 +1,5 @@ +import type { ModuleSerializedStateMap } from "@modules/ModuleSerializedStateMap"; + import type { KeyKind } from "./DataChannelTypes"; import type { LayoutElement } from "./internal/Dashboard"; import type { SyncSettingKey } from "./SyncSettings"; @@ -10,36 +12,55 @@ export type DataChannelTemplate = { export type TemplateLayoutElement = Omit; +export type TemplateModuleInstance = { + instanceRef?: string; + moduleName: M; + layout: TemplateLayoutElement; + syncedSettings?: SyncSettingKey[]; + dataChannelsToInitialSettingsMapping?: Record; + initialState?: { + settings?: ModuleSerializedStateMap[M]["settings"]; + view?: ModuleSerializedStateMap[M]["view"]; + }; +}; + export type Template = { + name: string; description: string; - moduleInstances: { - instanceRef?: string; - moduleName: string; - layout: TemplateLayoutElement; - syncedSettings?: SyncSettingKey[]; - dataChannelsToInitialSettingsMapping?: Record; - initialSettings?: Record; - }[]; + moduleInstances: TemplateModuleInstance[]; }; +export function createTemplateModuleInstance( + moduleName: M, + options: Omit, "moduleName">, +): TemplateModuleInstance { + return { + moduleName, + ...options, + }; +} + export class TemplateRegistry { - private static _registeredTemplates: Record = {}; + private static _registeredTemplates: Template[] = []; private constructor() {} - static registerTemplate(name: string, template: Template): void { - this._registeredTemplates[name] = template; + static registerTemplate(template: Template): void { + if (this._registeredTemplates.find((t) => t.name === template.name)) { + throw new Error(`Template with name ${template.name} already registered.`); + } + this._registeredTemplates.push(template); } - static getRegisteredTemplates(): Record { + static getRegisteredTemplates(): Template[] { return this._registeredTemplates; } - static getTemplate(name: string): Template | undefined { - const template = this._registeredTemplates[name]; - if (template) { - return template; + static getTemplate(name: string): Template { + const template = this._registeredTemplates.find((t) => t.name === name); + if (!template) { + throw new Error(`Template with name ${name} not registered.`); } - throw new Error(`Template with name ${name} not registered.`); + return template; } } diff --git a/frontend/src/framework/Workbench.ts b/frontend/src/framework/Workbench.ts index 0bf119a60..989fb8073 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -1,25 +1,46 @@ import type { QueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { toast } from "react-toastify"; +import { + deleteSessionMutation, + deleteSnapshotMutation, + getSnapshotAccessLogsQueryKey, + updateSessionMutation, + type SessionUpdate_api, +} from "@api"; import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; -import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; -import { AtomStoreMaster } from "./AtomStoreMaster"; +import { ConfirmationService } from "./ConfirmationService"; import { GuiMessageBroker, GuiState, LeftDrawerContent, RightDrawerContent } from "./GuiMessageBroker"; -import { DashboardTopic } from "./internal/Dashboard"; +import { Dashboard } from "./internal/Dashboard"; import { EnsembleUpdateMonitor } from "./internal/EnsembleUpdateMonitor"; +import { NavigationObserver } from "./internal/NavigationObserver"; import { PrivateWorkbenchServices } from "./internal/PrivateWorkbenchServices"; +import { PrivateWorkbenchSession } from "./internal/WorkbenchSession/PrivateWorkbenchSession"; +import { + removeSessionQueryData, + removeSnapshotQueryData, + replaceSessionQueryData, +} from "./internal/WorkbenchSession/utils/crudHelpers"; import { - PrivateWorkbenchSession, - PrivateWorkbenchSessionTopic, -} from "./internal/WorkbenchSession/PrivateWorkbenchSession"; -import { loadWorkbenchSessionFromLocalStorage } from "./internal/WorkbenchSession/utils/loaders"; + loadAllWorkbenchSessionsFromLocalStorage, + loadSnapshotFromBackend, + loadWorkbenchSessionFromBackend, + loadWorkbenchSessionFromLocalStorage, +} from "./internal/WorkbenchSession/utils/loaders"; import { localStorageKeyForSessionId } from "./internal/WorkbenchSession/utils/localStorageHelpers"; -import { makeWorkbenchSessionLocalStorageString } from "./internal/WorkbenchSession/utils/serialization"; +import { + buildSessionUrl, + readSessionIdFromUrl, + readSnapshotIdFromUrl, + removeSessionIdFromUrl, + removeSnapshotIdFromUrl, +} from "./internal/WorkbenchSession/utils/url"; +import { WorkbenchSessionPersistenceService } from "./internal/WorkbenchSessionPersistenceService"; import type { Template } from "./TemplateRegistry"; -import { UserCreatedItemsEvent } from "./UserCreatedItems"; +import { ApiErrorHelper } from "./utils/ApiErrorHelper"; import type { WorkbenchServices } from "./WorkbenchServices"; -import { WorkbenchSessionTopic } from "./WorkbenchSession"; -import { WorkbenchSettingsTopic } from "./WorkbenchSettings"; export enum WorkbenchTopic { ACTIVE_SESSION = "activeSession", @@ -36,21 +57,24 @@ export class Workbench implements PublishSubscribe { private _workbenchSession: PrivateWorkbenchSession | null = null; private _workbenchServices: PrivateWorkbenchServices; private _guiMessageBroker: GuiMessageBroker; - private _atomStoreMaster: AtomStoreMaster; private _queryClient: QueryClient; + private _workbenchSessionPersistenceService: WorkbenchSessionPersistenceService; + private _navigationObserver: NavigationObserver; private _ensembleUpdateMonitor: EnsembleUpdateMonitor; private _isInitialized: boolean = false; - private _unsubscribeFunctionsManagerDelegate = new UnsubscribeFunctionsManagerDelegate(); - private _pullDebounceTimeout: ReturnType | null = null; - private _pullInProgress = false; - private _pullCounter = 0; constructor(queryClient: QueryClient) { this._queryClient = queryClient; - this._atomStoreMaster = new AtomStoreMaster(); this._workbenchServices = new PrivateWorkbenchServices(this); this._guiMessageBroker = new GuiMessageBroker(); this._ensembleUpdateMonitor = new EnsembleUpdateMonitor(queryClient, this); + + this._navigationObserver = new NavigationObserver({ + onBeforeUnload: () => this.isWorkbenchDirty(), + onNavigate: async () => this.handleNavigation(), + }); + + this._workbenchSessionPersistenceService = new WorkbenchSessionPersistenceService(this); } getPublishSubscribeDelegate(): PublishSubscribeDelegate { @@ -74,6 +98,102 @@ export class Workbench implements PublishSubscribe { return this._queryClient; } + getWorkbenchSessionPersistenceService(): WorkbenchSessionPersistenceService { + return this._workbenchSessionPersistenceService; + } + + private isWorkbenchDirty(): boolean { + if (!this._workbenchSession) { + return false; // No active session, so nothing to save. + } + + return ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ); + } + + async handleNavigation(): Promise { + // When the user navigates with forward/backward buttons, they might want to load a snapshot. In this case, + // we first have to check if a snapshot id is present in the URL. + // If it is, we have to check for unsaved changes and then load the snapshot. + const snapshotId = readSnapshotIdFromUrl(); + const sessionId = readSessionIdFromUrl(); + if (!snapshotId && !sessionId) { + // No snapshot/session id in URL, so we can proceed with the navigation - if a session is active, it will be closed. + if (this._workbenchSession) { + await this.maybeCloseCurrentSession(); + return true; // Proceed with the navigation. + } + } + + if (this._workbenchSession) { + if (this._workbenchSession.isSnapshot()) { + // If the current session is a snapshot, we can just load the new entity. + if (snapshotId) { + await this.openSnapshot(snapshotId); + } else if (sessionId) { + if (this._workbenchSession.getId() === sessionId) { + // If the session id is the same as the current session, we do not need to reload it. + return true; // Proceed with the navigation. + } + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } + + // If the current session is not a snapshot, we have to check for unsaved changes. + if (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) { + // If there are unsaved changes, we show a dialog to the user to confirm if they want to discard the + // current session and load the new one. + const result = await ConfirmationService.confirm({ + title: "Unsaved Changes", + message: "You have unsaved changes in your current session. Do you want to save or discard them?", + actions: [ + { id: "save", label: "Save Changes", color: "success" }, + { id: "discard", label: "Discard Changes", color: "danger" }, + { id: "cancel", label: "Cancel" }, + ], + }); + + if (result === "cancel") { + // User chose to cancel the navigation, so we do nothing. + return false; + } + if (result === "save") { + // User chose to save the changes, so we save the current session and then load the new snapshot. + await this.saveCurrentSession(true); + if (snapshotId) { + await this.openSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } + if (result === "discard") { + // User chose to discard the changes, so we discard the current session and load the new snapshot. + this.unloadCurrentSession(); + if (snapshotId) { + await this.openSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } + + throw new Error(`Unexpected confirmation result: ${result}`); + } + } + + // If there are no unsaved changes or no active session, we can just load the new snapshot or session. + if (snapshotId) { + await this.openSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } + async initialize() { if (this._isInitialized) { console.info( @@ -84,17 +204,96 @@ export class Workbench implements PublishSubscribe { this._isInitialized = true; - const key = localStorageKeyForSessionId("default"); - const hasSessionInLocalStorage = window.localStorage.getItem(key) !== null; - if (hasSessionInLocalStorage) { - await this.openSessionFromLocalStorage("default", true); - } else { - await this.startNewSession(); + // First, check if a snapshot id is provided in the URL + const snapshotId = readSnapshotIdFromUrl(); + const sessionId = readSessionIdFromUrl(); + + const storedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + if (snapshotId) { + this.openSnapshot(snapshotId); + return; + } else if (sessionId) { + this.openSession(sessionId); + if (storedSessions.find((el) => el.id === sessionId)) { + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, true); + } + return; } + if (storedSessions.length > 0) { + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, true); + } + } + + async openSnapshot(snapshotId: string): Promise { + try { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + const snapshotData = await loadSnapshotFromBackend(this._queryClient, snapshotId); + const snapshot = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, snapshotData); + await this.setWorkbenchSession(snapshot); + if (this.getGuiMessageBroker().getState(GuiState.LeftDrawerContent) !== LeftDrawerContent.ModuleSettings) { + this._guiMessageBroker.setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); + } + if (this.getGuiMessageBroker().getState(GuiState.RightDrawerContent) === RightDrawerContent.ModulesList) { + this._guiMessageBroker.setState( + GuiState.RightDrawerContent, + RightDrawerContent.RealizationFilterSettings, + ); + this._guiMessageBroker.setState(GuiState.RightSettingsPanelWidthInPercent, 0); + } + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + return; + } catch (error: any) { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + console.error("Failed to load snapshot from backend:", error); + + if (isAxiosError(error)) { + // Handle Axios error specifically + console.error("Axios error details:", error.response?.data); + } + + const apiError = ApiErrorHelper.fromError(error); + + const result = await ConfirmationService.confirm({ + title: "Could not load snapshot", + message: apiError?.getMessage() ?? "Could not open snapshot", + actions: [ + { + id: "cancel", + label: "Cancel", + }, + { + id: "retry", + label: "Retry", + }, + ], + }); + if (result === "retry") { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + // Retry loading the snapshot + await this.openSnapshot(snapshotId); + } + } + } + + makeSessionFromSnapshot(): void { if (!this._workbenchSession) { - throw new Error("Failed to initialize workbench session."); + throw new Error("No active workbench session."); } + + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession.setMetadata({ + title: "New Session from Snapshot", + description: undefined, + createdAt: Date.now(), + updatedAt: Date.now(), + lastModifiedMs: Date.now(), + }); + this._workbenchSession.setIsSnapshot(false); + this._workbenchSession.setIsPersisted(false); + this._workbenchSessionPersistenceService.setWorkbenchSession(this._workbenchSession); + removeSnapshotIdFromUrl(); } discardLocalStorageSession(snapshotId: string | null, unloadSession = true): void { @@ -105,6 +304,9 @@ export class Workbench implements PublishSubscribe { return; } + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); this._workbenchSession = null; this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); } @@ -116,19 +318,20 @@ export class Workbench implements PublishSubscribe { } try { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + const sessionData = await loadWorkbenchSessionFromLocalStorage(sessionId); if (!sessionData) { console.warn("No workbench session found in local storage."); return; } - const session = await PrivateWorkbenchSession.fromDataContainer( - this._atomStoreMaster, - this._queryClient, - sessionData, - ); + const session = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, sessionData); await this.setWorkbenchSession(session); + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, false); + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, false); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); } catch (error) { console.error("Failed to load workbench session from local storage:", error); if (confirm("Could not load workbench session from local storage. Discard corrupted session?")) { @@ -139,6 +342,130 @@ export class Workbench implements PublishSubscribe { this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); } + async openSession(sessionId: string): Promise { + if (this._workbenchSession) { + console.warn("A workbench session is already active. Please close it before opening a new one."); + return; + } + + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + + const url = buildSessionUrl(sessionId); + window.history.pushState({}, "", url); + + try { + const sessionData = await loadWorkbenchSessionFromBackend(this._queryClient, sessionId); + const session = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, sessionData); + await this.setWorkbenchSession(session); + } catch (error) { + console.error("Failed to load session from backend:", error); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + const result = await ConfirmationService.confirm({ + title: "Could not load session", + message: `Could not load session with ID ${sessionId}. The session might not exist or you might not have access to it.`, + actions: [ + { + id: "cancel", + label: "Cancel", + }, + { + id: "retry", + label: "Retry", + }, + ], + }); + if (result === "retry") { + // Retry loading the session + await this.openSession(sessionId); + } + } finally { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + } + } + + async updateSession(sessionId: string, sessionUpdate: SessionUpdate_api): Promise { + const queryClient = this._queryClient; + + this._guiMessageBroker.setState(GuiState.IsSavingSession, true); + + let success = false; + + await queryClient + .getMutationCache() + .build(queryClient, { + ...updateSessionMutation(), + onSuccess(data) { + replaceSessionQueryData(queryClient, data); + toast.success("Session successfully updated."); + success = true; + }, + onError(error) { + console.error("Failed to update session:", error); + const apiError = ApiErrorHelper.fromError(error); + if (!apiError) { + toast.error("An unknown error occurred while updating the session."); + return; + } + console.error("API error details:", apiError.getMessage()); + toast.error(`Failed to update session: ${apiError.getMessage()}`); + }, + }) + .execute({ path: { session_id: sessionId }, body: sessionUpdate }); + + this._guiMessageBroker.setState(GuiState.IsSavingSession, false); + + return success; + } + + async makeSnapshot(title: string, description: string): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to make a snapshot."); + } + + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, true); + + const snapshotId = await this._workbenchSessionPersistenceService.makeSnapshot(title, description); + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, false); + + // Reset this, so it'll fetch fresh copies - is this working without any options? + this._queryClient.resetQueries({ queryKey: getSnapshotAccessLogsQueryKey() }); + + return snapshotId; + } + + saveCurrentSessionAs(): void { + if (!this._workbenchSession) { + throw new Error("No active workbench session to save."); + } + + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + } + + async saveCurrentSession(forceSave = false): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to save."); + } + + if (this._workbenchSession.getIsPersisted() || forceSave) { + this._guiMessageBroker.setState(GuiState.IsSavingSession, true); + const wasPersisted = this._workbenchSession.getIsPersisted(); + await this._workbenchSessionPersistenceService.persistSessionState(); + const id = this._workbenchSession.getId(); + if (!wasPersisted && id) { + const url = buildSessionUrl(id); + window.history.pushState({}, "", url); + } + this._guiMessageBroker.setState(GuiState.IsSavingSession, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + return; + } + + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + } + private async setWorkbenchSession(session: PrivateWorkbenchSession): Promise { try { if (session.getEnsembleSet().getEnsembleArray().length === 0) { @@ -155,14 +482,17 @@ export class Workbench implements PublishSubscribe { } this._workbenchSession = session; - this.subscribeToSessionChanges(); await this._ensembleUpdateMonitor.pollImmediately(); this._ensembleUpdateMonitor.startPolling(); + await this._workbenchSessionPersistenceService.setWorkbenchSession(session); this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.ACTIVE_SESSION); } catch (error) { console.error("Failed to hydrate workbench session:", error); throw new Error("Could not load workbench session from data container."); + } finally { + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); } } @@ -172,179 +502,236 @@ export class Workbench implements PublishSubscribe { return; } - const session = PrivateWorkbenchSession.createEmpty(this._atomStoreMaster, this._queryClient); + const session = PrivateWorkbenchSession.createEmpty(this._queryClient); await this.setWorkbenchSession(session); } - getAtomStoreMaster(): AtomStoreMaster { - return this._atomStoreMaster; - } - - getWorkbenchSession(): PrivateWorkbenchSession { + async maybeCloseCurrentSession(): Promise { if (!this._workbenchSession) { - throw new Error("Workbench session has not been started. Call startNewSession() first."); + console.warn("No active workbench session to close."); + return true; } - return this._workbenchSession; - } - getWorkbenchServices(): WorkbenchServices { - return this._workbenchServices; - } + if ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ) { + const result = await ConfirmationService.confirm({ + title: "Unsaved Changes", + message: "You have unsaved changes in your current session. Do you want to save them before closing?", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "discard", label: "Discard Changes", color: "danger" }, + { id: "save", label: "Save Changes", color: "success" }, + ], + }); + + if (result === "cancel") { + // User chose to cancel the close operation, so we do nothing. + return false; + } + if (result === "save") { + // User chose to save the changes, so we save the current session and then close it + if (!this._workbenchSession.getIsPersisted()) { + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + return false; // Do not proceed with the close operation, wait for the user to save + } + await this.saveCurrentSession(true); + this.closeCurrentSession(); + return true; // Proceed with the close operation. + } + if (result === "discard") { + // User chose to discard the changes, so we unload the current session and close it. + this.closeCurrentSession(); + return true; // Proceed with the close operation. + } - getGuiMessageBroker(): GuiMessageBroker { - return this._guiMessageBroker; + throw new Error(`Unexpected confirmation result: ${result}`); + } + + this.closeCurrentSession(); + return true; } - applyTemplate(template: Template): void { + async maybeRefreshSession(): Promise { if (!this._workbenchSession) { - throw new Error("No active workbench session."); + console.warn("No active workbench session to refresh."); + return; } - const dashboard = this._workbenchSession.getActiveDashboard(); - dashboard.applyTemplate(template); - } + if (this._workbenchSession.isSnapshot() || !this._workbenchSession.getIsPersisted()) { + throw new Error("Cannot refresh a snapshot or non-persisted session."); + } - private subscribeToSessionChanges() { - if (!this._workbenchSession) { - throw new Error("No active workbench session to subscribe to changes."); + const sessionId = this._workbenchSession.getId(); + + if (!sessionId) { + throw new Error("Cannot refresh session without a valid session ID."); } - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "workbench-session", - this._workbenchSession - .getPublishSubscribeDelegate() - .makeSubscriberFunction(PrivateWorkbenchSessionTopic.DASHBOARDS)(() => { - this.schedulePullFullSessionState(); - this.subscribeToDashboardUpdates(); - }), - ); + if ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ) { + const result = await ConfirmationService.confirm({ + title: "Unsaved Changes", + message: + "You have unsaved changes in your current session. By refreshing, you will lose these changes. Do you want to proceed?", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "discard", label: "Discard & Refresh", color: "danger" }, + ], + }); + + if (result === "cancel") { + // User chose to cancel the close operation, so we do nothing. + return; + } + if (result === "discard") { + // User chose to discard the changes, so we unload the current session and refresh it. + this.unloadCurrentSession(); + await this.openSession(sessionId); + return; + } - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "workbench-session", - this._workbenchSession - .getPublishSubscribeDelegate() - .makeSubscriberFunction(WorkbenchSessionTopic.ENSEMBLE_SET)(() => { - this.schedulePullFullSessionState(); - }), - ); + throw new Error(`Unexpected confirmation result: ${result}`); + } - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "workbench-session", - this._workbenchSession - .getPublishSubscribeDelegate() - .makeSubscriberFunction(WorkbenchSessionTopic.REALIZATION_FILTER_SET)(() => { - this.schedulePullFullSessionState(); - }), - ); + this.unloadCurrentSession(); + await this.openSession(sessionId); + } - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "workbench-session", - this._workbenchSession - .getWorkbenchSettings() - .getPublishSubscribeDelegate() - .makeSubscriberFunction(WorkbenchSettingsTopic.SELECTED_COLOR_PALETTE_IDS)(() => { - this.schedulePullFullSessionState(); - }), - ); + private unloadCurrentSession(): void { + if (!this._workbenchSession) { + console.warn("No active workbench session to unload."); + return; + } - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "workbench-session", - this._workbenchSession - .getUserCreatedItems() - .subscribe(UserCreatedItemsEvent.INTERSECTION_POLYLINES_CHANGE, () => { - this.schedulePullFullSessionState(); - }), - ); + this._ensembleUpdateMonitor.stopPolling(); - this.subscribeToDashboardUpdates(); + this._workbenchSession.beforeDestroy(); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession = null; } - private subscribeToDashboardUpdates() { + closeCurrentSession(): void { if (!this._workbenchSession) { - throw new Error("No active workbench session to subscribe to dashboard updates."); + console.warn("No active workbench session to close."); + return; } - this._unsubscribeFunctionsManagerDelegate.unsubscribe("dashboards"); + removeSnapshotIdFromUrl(); + removeSessionIdFromUrl(); + this.unloadCurrentSession(); - const dashboards = this._workbenchSession.getDashboards(); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); + } - for (const dashboard of dashboards) { - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "dashboards", - dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.Layout)(() => { - this.schedulePullFullSessionState(); - }), - ); - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "dashboards", - dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ModuleInstances)(() => { - this.schedulePullFullSessionState(); - }), - ); - this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( - "dashboards", - dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ActiveModuleInstanceId)( - () => { - this.schedulePullFullSessionState(); + async deleteSession(sessionId: string): Promise { + const result = await ConfirmationService.confirm({ + title: "Are you sure?", + message: + "This session will be deleted. This action can not be reversed. Note that any snapshots made from this session will still be available", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "delete", label: "Delete", color: "danger" }, + ], + }); + + if (result !== "delete") return false; + + let success = false; + + try { + await this._queryClient + .getMutationCache() + .build(this._queryClient, { + ...deleteSessionMutation(), + onSuccess: () => { + success = true; + removeSessionQueryData(this._queryClient, sessionId); }, - ), - ); + }) + .execute({ path: { session_id: sessionId } }); + } catch (error) { + toast.error("An error occurred while deleting the session."); + console.error("Failed to delete session:", error); } + + return success; } - private schedulePullFullSessionState(delay: number = 200) { - this.maybeClearPullDebounceTimeout(); + async deleteSnapshot(snapshotId: string): Promise { + const result = await ConfirmationService.confirm({ + title: "Are you sure?", + message: "This snapshot will be deleted. This action can not be reversed.", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "delete", label: "Delete", color: "danger" }, + ], + }); - this._pullDebounceTimeout = setTimeout(() => { - this._pullDebounceTimeout = null; - this.pullFullSessionState(); - }, delay); - } + if (result !== "delete") return false; + + let success = false; - private maybeClearPullDebounceTimeout() { - if (this._pullDebounceTimeout) { - clearTimeout(this._pullDebounceTimeout); - this._pullDebounceTimeout = null; + try { + await this._queryClient + .getMutationCache() + .build(this._queryClient, { + ...deleteSnapshotMutation(), + onSuccess: () => { + success = true; + removeSnapshotQueryData(this._queryClient, snapshotId); + }, + }) + .execute({ path: { snapshot_id: snapshotId } }); + } catch (error) { + toast.error("An error occurred while deleting the snapshot."); + console.error("Failed to delete snapshot:", error); } + + return success; } - private async pullFullSessionState({ immediate = false } = {}): Promise { + getWorkbenchSession(): PrivateWorkbenchSession { if (!this._workbenchSession) { - console.warn("No active workbench session to pull state from."); - return false; + throw new Error("Workbench session has not been started. Call startNewSession() first."); } + return this._workbenchSession; + } - if (this._pullInProgress && !immediate) { - // Do not allow concurrent pulls – let debounce handle retries - return false; - } + getWorkbenchServices(): WorkbenchServices { + return this._workbenchServices; + } - this._pullInProgress = true; - const localPullId = ++this._pullCounter; + getGuiMessageBroker(): GuiMessageBroker { + return this._guiMessageBroker; + } - try { - // Only apply if it's still the latest pull - if (localPullId !== this._pullCounter) { - return false; - } + beforeDestroy(): void { + this._navigationObserver.beforeDestroy(); + } - this.persistToLocalStorage(); + clear(): void { + // this._workbenchSession.clear(); + } - return true; - } catch (error) { - console.error("Failed to pull full session state:", error); - return false; - } finally { - this._pullInProgress = false; + async makeSessionFromTemplate(template: Template): Promise { + if (this._workbenchSession) { + this._workbenchSession.clear(); } - } - private persistToLocalStorage() { - const key = localStorageKeyForSessionId("default"); + await this.startNewSession(); - if (this._workbenchSession) { - localStorage.setItem(key, makeWorkbenchSessionLocalStorageString(this._workbenchSession)); + if (!this._workbenchSession) { + throw new Error("No active workbench session to apply the template to."); } + + const dashboard = await Dashboard.fromTemplate(template, this._workbenchSession.getAtomStoreMaster()); + this._workbenchSession.setDashboards([dashboard]); + + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); } } diff --git a/frontend/src/framework/internal/Dashboard.ts b/frontend/src/framework/internal/Dashboard.ts index d74079985..bfc079319 100644 --- a/frontend/src/framework/internal/Dashboard.ts +++ b/frontend/src/framework/internal/Dashboard.ts @@ -1,13 +1,12 @@ import type { JTDSchemaType } from "ajv/dist/core"; import { v4 } from "uuid"; -import { InitialSettings } from "@framework/InitialSettings"; import { SyncSettingKey } from "@framework/SyncSettings"; import type { Template } from "@framework/TemplateRegistry"; import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; import type { AtomStoreMaster } from "../AtomStoreMaster"; -import type { ModuleInstance, ModuleInstanceFullState } from "../ModuleInstance"; +import type { ModuleInstance, ModuleInstanceSerializedState } from "../ModuleInstance"; import { ModuleRegistry } from "../ModuleRegistry"; export type LayoutElement = { @@ -21,7 +20,7 @@ export type LayoutElement = { maximized?: boolean; }; -export type ModuleInstanceStateAndLayoutInfo = ModuleInstanceFullState & { +export type ModuleInstanceStateAndLayoutInfo = ModuleInstanceSerializedState & { layoutInfo: Omit; }; @@ -50,6 +49,13 @@ const moduleInstanceSchema: JTDSchemaType = { properties: { id: { type: "string" }, name: { type: "string" }, + serializedState: { + optionalProperties: { + view: { type: "string" }, + settings: { type: "string" }, + }, + nullable: true, + }, syncedSettingKeys: { elements: { enum: Object.values(SyncSettingKey), @@ -93,7 +99,7 @@ export enum DashboardTopic { export type DashboardTopicPayloads = { [DashboardTopic.Layout]: LayoutElement[]; - [DashboardTopic.ModuleInstances]: ModuleInstance[]; + [DashboardTopic.ModuleInstances]: ModuleInstance[]; [DashboardTopic.ActiveModuleInstanceId]: string | null; }; @@ -104,7 +110,7 @@ export class Dashboard implements PublishSubscribe { private _name: string; private _description?: string; private _layout: LayoutElement[] = []; - private _moduleInstances: ModuleInstance[] = []; + private _moduleInstances: ModuleInstance[] = []; private _activeModuleInstanceId: string | null = null; private _atomStoreMaster: AtomStoreMaster; @@ -153,64 +159,13 @@ export class Dashboard implements PublishSubscribe { this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); } - applyTemplate(template: Template): void { - this.clearLayout(); - - template.moduleInstances.forEach((el) => { - this.makeAndAddModuleInstance(el.moduleName, { ...el.layout, moduleName: el.moduleName }); - }); - - for (let i = 0; i < this._moduleInstances.length; i++) { - const moduleInstance = this._moduleInstances[i]; - const templateModule = template.moduleInstances[i]; - if (templateModule.syncedSettings) { - for (const syncSettingKey of templateModule.syncedSettings) { - moduleInstance.addSyncedSetting(syncSettingKey); - } - } - - const initialSettings: Record = templateModule.initialSettings || {}; - - if (templateModule.dataChannelsToInitialSettingsMapping) { - for (const propName of Object.keys(templateModule.dataChannelsToInitialSettingsMapping)) { - const dataChannel = templateModule.dataChannelsToInitialSettingsMapping[propName]; - - const moduleInstanceIndex = template.moduleInstances.findIndex( - (el) => el.instanceRef === dataChannel.listensToInstanceRef, - ); - if (moduleInstanceIndex === -1) { - throw new Error("Could not find module instance for data channel"); - } - - const listensToModuleInstance = this._moduleInstances[moduleInstanceIndex]; - const channel = listensToModuleInstance.getChannelManager().getChannel(dataChannel.channelIdString); - if (!channel) { - throw new Error("Could not find channel"); - } - - const receiver = moduleInstance.getChannelManager().getReceiver(propName); - - if (!receiver) { - throw new Error("Could not find receiver"); - } - - receiver.subscribeToChannel(channel, "All"); - } - } - - moduleInstance.setInitialSettings(new InitialSettings(initialSettings)); - } - - this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); - } - - getModuleInstances(): ModuleInstance[] { + getModuleInstances(): ModuleInstance[] { return this._moduleInstances; } serializeState(): SerializedDashboard { const moduleInstances = this._moduleInstances.map((moduleInstance) => { - const fullState = moduleInstance.getFullState(); + const moduleState = moduleInstance.serialize(); const layoutInfo = this._layout.find((el) => el.moduleInstanceId === moduleInstance.getId()); @@ -219,7 +174,7 @@ export class Dashboard implements PublishSubscribe { } return { - ...fullState, + ...moduleState, layoutInfo: { relX: layoutInfo.relX, relY: layoutInfo.relY, @@ -248,15 +203,26 @@ export class Dashboard implements PublishSubscribe { this.clearLayout(); for (const serializedInstance of serializedDashboard.moduleInstances) { - const { id, name, layoutInfo } = serializedInstance; + const { id, name } = serializedInstance; const module = ModuleRegistry.getModule(name); if (!module) { throw new Error(`Module ${name} not found`); } - const moduleInstance = module.makeInstance(id); - moduleInstance.setFullState(serializedInstance); + const moduleInstance = module.makeInstance(id, this._atomStoreMaster); this.registerModuleInstance(moduleInstance); + } + + // Doing this after all module instances have been registered + // ensures that the module instances are available for data channel initialization. + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + const moduleInstance = this.getModuleInstance(id); + if (!moduleInstance) { + throw new Error(`Module instance with ID ${id} not found`); + } + + moduleInstance.initiateDeserialization(serializedInstance, this); this._layout.push({ moduleInstanceId: id, @@ -284,23 +250,24 @@ export class Dashboard implements PublishSubscribe { this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); } - registerModuleInstance(moduleInstance: ModuleInstance): void { + registerModuleInstance(moduleInstance: ModuleInstance): void { this._moduleInstances = [...this._moduleInstances, moduleInstance]; - this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); } - makeAndAddModuleInstance(moduleName: string, layout: LayoutElement): ModuleInstance { + makeAndAddModuleInstance(moduleName: string): ModuleInstance { const module = ModuleRegistry.getModule(moduleName); if (!module) { throw new Error(`Module ${moduleName} not found`); } - const moduleInstance = module.makeInstance(v4()); - this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); + const id = v4(); + const moduleInstance = module.makeInstance(id, this._atomStoreMaster); this._moduleInstances = [...this._moduleInstances, moduleInstance]; + if (this._moduleInstances.length === 1) { + this._activeModuleInstanceId = moduleInstance.getId(); + } - this._layout = [...this._layout, { ...layout, moduleInstanceId: moduleInstance.getId() }]; this._activeModuleInstanceId = moduleInstance.getId(); this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); @@ -330,7 +297,7 @@ export class Dashboard implements PublishSubscribe { this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); } - getModuleInstance(id: string): ModuleInstance | undefined { + getModuleInstance(id: string): ModuleInstance | undefined { return this._moduleInstances.find((moduleInstance) => moduleInstance.getId() === id); } @@ -351,19 +318,31 @@ export class Dashboard implements PublishSubscribe { dashboard._id = serializedDashboard.id; dashboard._name = serializedDashboard.name; dashboard._description = serializedDashboard.description; + dashboard._activeModuleInstanceId = serializedDashboard.activeModuleInstanceId; const layout: LayoutElement[] = []; for (const serializedInstance of serializedDashboard.moduleInstances) { - const { id, name, layoutInfo } = serializedInstance; + const { id, name } = serializedInstance; const module = ModuleRegistry.getModule(name); if (!module) { throw new Error(`Module ${name} not found`); } - const moduleInstance = module.makeInstance(id); - moduleInstance.setFullState(serializedInstance); + const moduleInstance = module.makeInstance(id, atomStoreMaster); dashboard.registerModuleInstance(moduleInstance); + } + + // Doing this after all module instances have been registered + // ensures that the module instances are available for data channel initialization. + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + const moduleInstance = dashboard.getModuleInstance(id); + if (!moduleInstance) { + throw new Error(`Module instance with ID ${id} not found`); + } + + moduleInstance.initiateDeserialization(serializedInstance, dashboard); layout.push({ moduleInstanceId: id, @@ -381,4 +360,85 @@ export class Dashboard implements PublishSubscribe { return dashboard; } + + static async fromTemplate(template: Template, atomStoreMaster: AtomStoreMaster): Promise { + const dashboard = new Dashboard(atomStoreMaster); + dashboard._id = v4(); + dashboard._description = template.description; + + const layout: LayoutElement[] = []; + const moduleInstances: ModuleInstance[] = []; + const moduleInstanceRefMap: Record> = {}; + + for (const module of template.moduleInstances) { + const moduleInstance = await dashboard.makeAndAddModuleInstance(module.moduleName); + layout.push({ + moduleInstanceId: moduleInstance.getId(), + moduleName: module.moduleName, + relX: module.layout.relX, + relY: module.layout.relY, + relHeight: module.layout.relHeight, + relWidth: module.layout.relWidth, + minimized: module.layout.minimized, + maximized: module.layout.maximized, + }); + + if (module.syncedSettings) { + for (const syncedSetting of module.syncedSettings) { + moduleInstance.addSyncedSetting(syncedSetting); + } + } + + if (module.instanceRef) { + moduleInstanceRefMap[module.instanceRef] = moduleInstance; + } + + if (module.initialState) { + moduleInstance.initiateTemplateStateApplication(module.initialState); + } + + moduleInstances.push(moduleInstance); + } + + for (const [idx, module] of template.moduleInstances.entries()) { + const moduleInstance = moduleInstances[idx]; + if (!moduleInstance) { + throw new Error(`Module instance with reference ${module.instanceRef} not found`); + } + + if (module.dataChannelsToInitialSettingsMapping) { + for (const [key, dataChannelConfig] of Object.entries(module.dataChannelsToInitialSettingsMapping)) { + const listensToModuleInstance = moduleInstanceRefMap[dataChannelConfig.listensToInstanceRef]; + if (!listensToModuleInstance) { + throw new Error( + `Module instance with reference ${dataChannelConfig.listensToInstanceRef} not found`, + ); + } + + const channel = listensToModuleInstance + .getChannelManager() + .getChannel(dataChannelConfig.channelIdString); + + if (!channel) { + throw new Error( + `Channel with ID ${dataChannelConfig.channelIdString} not found in module instance ${moduleInstance.getId()}`, + ); + } + + const receiver = moduleInstance.getChannelManager().getReceiver(key); + if (!receiver) { + throw new Error( + `Receiver with ID ${key} not found in module instance ${moduleInstance.getId()}`, + ); + } + + receiver.subscribeToChannel(channel, "All"); + } + } + } + + dashboard.setLayout(layout); + + return dashboard; + } } diff --git a/frontend/src/framework/internal/DataChannels/ChannelManager.ts b/frontend/src/framework/internal/DataChannels/ChannelManager.ts index 30294a3d3..643610d8e 100644 --- a/frontend/src/framework/internal/DataChannels/ChannelManager.ts +++ b/frontend/src/framework/internal/DataChannels/ChannelManager.ts @@ -1,3 +1,5 @@ +import type { ModuleInstance } from "@framework/ModuleInstance"; + import type { ChannelDefinition } from "./Channel"; import { Channel } from "./Channel"; import type { ChannelReceiverDefinition } from "./ChannelReceiver"; @@ -8,6 +10,13 @@ export enum ChannelManagerNotificationTopic { RECEIVERS_CHANGE = "receivers-change", } +export type SerializedDataChannelReceiverSubscription = { + idString: string; + listensToModuleInstanceId: string; + channelIdString: string; + contentIdStrings: string[]; +}; + export class ChannelManager { private readonly _moduleInstanceId: string; private _channels: Channel[] = []; @@ -86,6 +95,61 @@ export class ChannelManager { }; } + serialize(): SerializedDataChannelReceiverSubscription[] { + const subscriptions: SerializedDataChannelReceiverSubscription[] = []; + + for (const receiver of this._receivers) { + const channel = receiver.getChannel(); + if (!channel) { + continue; // Skip receivers that are not associated with a channel + } + + const subscription: SerializedDataChannelReceiverSubscription = { + idString: receiver.getIdString(), + listensToModuleInstanceId: channel.getManager().getModuleInstanceId(), + channelIdString: channel.getIdString(), + contentIdStrings: receiver.getContentIdStrings(), + }; + subscriptions.push(subscription); + } + + return subscriptions; + } + + deserialize( + subscriptions: SerializedDataChannelReceiverSubscription[], + moduleInstances: ModuleInstance[], + ): void { + for (const subscription of subscriptions) { + const listensToModuleInstance = moduleInstances.find( + (instance) => instance.getId() === subscription.listensToModuleInstanceId, + ); + if (!listensToModuleInstance) { + console.warn( + `ChannelManager.deserialize: Module instance with ID ${subscription.listensToModuleInstanceId} not found. Skipping subscription.`, + ); + continue; + } + const channel = listensToModuleInstance.getChannelManager().getChannel(subscription.channelIdString); + if (!channel) { + console.warn( + `ChannelManager.deserialize: Channel with ID ${subscription.channelIdString} not found. Skipping subscription.`, + ); + continue; + } + const receiver = this.getReceiver(subscription.idString); + if (!receiver) { + console.warn( + `ChannelManager.deserialize: Receiver with ID ${subscription.idString} not found. Skipping subscription.`, + ); + continue; + } + receiver.subscribeToChannel(channel, subscription.contentIdStrings); + } + + this.notifySubscribers(ChannelManagerNotificationTopic.RECEIVERS_CHANGE); + } + private notifySubscribers(topic: ChannelManagerNotificationTopic): void { const topicSubscribers = this._subscribersMap.get(topic); diff --git a/frontend/src/framework/internal/ModuleInstanceSerializer.ts b/frontend/src/framework/internal/ModuleInstanceSerializer.ts new file mode 100644 index 000000000..a4681ed3c --- /dev/null +++ b/frontend/src/framework/internal/ModuleInstanceSerializer.ts @@ -0,0 +1,285 @@ +import { Ajv, type ValidateFunction } from "ajv/dist/jtd"; +import { atom, type Atom, type Setter } from "jotai"; + +import type { AtomStore } from "@framework/AtomStoreMaster"; +import { + hasSerialization, + type ModuleComponentSerializationFunctions, + type ModuleComponentsStateBase, + type ModuleStateSchema, +} from "@framework/Module"; +import type { ModuleInstance, PartialSerializedModuleState } from "@framework/ModuleInstance"; +import { isPersistableAtom, Source } from "@framework/utils/atomUtils"; + +import { hashSessionContentString, objectToJsonString } from "./WorkbenchSession/utils/hash"; + +export class ModuleStateSerializationError extends Error { + constructor(message: string) { + super(message); + this.name = "ModuleStateSerializationError"; + } +} + +type StringifiedSerializedModuleComponentsState = { + settings?: string; + view?: string; +}; + +const ajv = new Ajv(); + +export class ModuleInstanceSerializer { + private _moduleInstance: ModuleInstance; + private _atomStore: AtomStore; + private _serializedStateSchema: ModuleStateSchema | null; + private _serializedState: TSerializedState | null = null; + private _serializationFunctions: ModuleComponentSerializationFunctions; + private _persistenceAtom: Atom; + private _lastSerializedHash: string | null = null; + private _debouncedNotifyChange: () => void; + private _validationFunctions: { + settings?: ValidateFunction; + view?: ValidateFunction; + }; + + constructor( + moduleInstance: ModuleInstance, + atomStore: AtomStore, + serializedStateSchema: ModuleStateSchema | null, + serializationFunctions: ModuleComponentSerializationFunctions, + onStateChange: () => void, + ) { + this._moduleInstance = moduleInstance; + this._atomStore = atomStore; + this._serializedStateSchema = serializedStateSchema; + this._serializationFunctions = serializationFunctions; + this._debouncedNotifyChange = debounce(() => { + onStateChange?.(); + }, 200); + + // Prepare validation functions + const validateSettings = this._serializedStateSchema?.settings + ? ajv.compile(this._serializedStateSchema.settings) + : undefined; + const validateView = this._serializedStateSchema?.view + ? ajv.compile(this._serializedStateSchema.view) + : undefined; + this._validationFunctions = { + settings: validateSettings, + view: validateView, + }; + + this._persistenceAtom = atom((get) => { + if (hasSerialization(this._serializationFunctions)) { + const result = { + settings: this._serializationFunctions.serializeStateFunctions.settings?.(get), + view: this._serializationFunctions.serializeStateFunctions.view?.(get), + } as TSerializedState; + return (result satisfies TSerializedState) ? result : undefined; + } + return undefined; // No serialization functions provided + }); + + this._atomStore + .sub(this._persistenceAtom, () => { + this.serializeState(); + }) + .bind(this); + } + + getSerializedState(): TSerializedState | null { + return this._serializedState; + } + + getStringifiedSerializedState(): StringifiedSerializedModuleComponentsState | null { + if (!this._serializedState) { + return null; // No serialized state available + } + + const stringifiedSettings = this._serializedState.settings + ? JSON.stringify(this._serializedState.settings) + : undefined; + + const stringifiedView = this._serializedState.view ? JSON.stringify(this._serializedState.view) : undefined; + + return { + settings: stringifiedSettings, + view: stringifiedView, + }; + } + + async serializeState() { + if (!hasSerialization(this._serializationFunctions) || !this._serializedStateSchema) { + return this._serializedState || {}; + } + + const serializedSettings = this._serializationFunctions.serializeStateFunctions.settings?.( + this._atomStore.get.bind(this._atomStore), + ); + + const serializedView = this._serializationFunctions.serializeStateFunctions.view?.( + this._atomStore.get.bind(this._atomStore), + ); + + if (serializedSettings === undefined && serializedView === undefined && this._serializedState === null) { + return {}; // No state to serialize + } + + // Validate against schema + if (this._serializedStateSchema.settings) { + const validateSettings = ajv.compile(this._serializedStateSchema.settings); + const isSettingsValid = serializedSettings === undefined || validateSettings(serializedSettings); + if (!isSettingsValid) { + console.warn(`Validation failed for ${this._moduleInstance.getName()}`, { + settingsErrors: validateSettings.errors, + }); + this._serializedState = null; + return; // Invalid state, do not serialize + } + } + + if (this._serializedStateSchema.view) { + const validateView = ajv.compile(this._serializedStateSchema.view); + const isViewValid = serializedView === undefined || validateView(serializedView); + + if (!isViewValid) { + console.warn(`Validation failed for ${this._moduleInstance.getName()}`, { + viewErrors: validateView.errors, + }); + this._serializedState = null; + return; // Invalid state, do not serialize + } + } + + const newSerializedState = { + settings: serializedSettings, + view: serializedView, + } as TSerializedState; + + const newHash = await hashSessionContentString(objectToJsonString(newSerializedState)); + + if (newHash !== this._lastSerializedHash) { + this._serializedState = newSerializedState; + this._lastSerializedHash = newHash; + this._debouncedNotifyChange?.(); + } + + this._serializedState = { + settings: serializedSettings, + view: serializedView, + } as TSerializedState; + } + + deserializeState(raw: StringifiedSerializedModuleComponentsState): void { + if (!this._serializedStateSchema) { + console.warn(`No serialized state schema defined for module instance ${this._moduleInstance.getName()}`); + return; // No schema defined, cannot deserialize + } + + if (!hasSerialization(this._serializationFunctions)) { + console.warn(`No serialization functions defined for module instance ${this._moduleInstance.getName()}`); + this._serializedState = null; + return; // No serialization functions, cannot deserialize + } + + let parsedSettings: unknown; + let parsedView: unknown; + try { + parsedSettings = raw.settings ? JSON.parse(raw.settings) : undefined; + parsedView = raw.view ? JSON.parse(raw.view) : undefined; + } catch (e) { + console.warn(`Invalid JSON in module state for instance ${this._moduleInstance.getName()}:`, e); + this._serializedState = null; + return; + } + + const validateSettings = this._validationFunctions.settings; + if (validateSettings) { + // If possible, compilation should only be performed once - as soon as the schema is available - move to constructor? + const isSettingsValid = parsedSettings === undefined || validateSettings(parsedSettings); + if (!isSettingsValid) { + console.warn(`Validation failed for settings in ${this._moduleInstance.getName()}`, { + settingsErrors: validateSettings.errors, + }); + throw new ModuleStateSerializationError( + `Invalid settings state for module instance ${this._moduleInstance.getName()}`, + ); + } + } + + const validateView = this._validationFunctions.view; + if (validateView) { + // If possible, compilation should only be performed once - as soon as the schema is available - move to constructor? + const isViewValid = parsedView === undefined || validateView(parsedView); + + if (!isViewValid) { + console.warn(`Validation failed for view in ${this._moduleInstance.getName()}`, { + viewErrors: validateView.errors, + }); + throw new ModuleStateSerializationError( + `Invalid view state for module instance ${this._moduleInstance.getName()}`, + ); + } + } + + this._serializedState = { + settings: parsedSettings as TSerializedState["settings"], + view: parsedView as TSerializedState["view"], + } as TSerializedState; + + this.applyStateToAtoms(this._serializedState); + } + + applyTemplateState(templateState: PartialSerializedModuleState): void { + this.applyStateToAtoms( + { + settings: templateState.settings, + view: templateState.view, + }, + true, + ); + } + + private applyStateToAtoms( + state: PartialSerializedModuleState, + fromTemplate: boolean = false, + ): void { + if (!hasSerialization(this._serializationFunctions)) { + console.warn(`No serialization functions defined for module instance ${this._moduleInstance.getName()}`); + return; // No serialization functions, cannot apply state + } + + const atomStore = this._atomStore; + + const persistedSetter: Setter = (atom, ...args) => { + const [value] = args; + const isPersistable = isPersistableAtom(atom); + + let finalValue = value; + if (isPersistable) { + if (fromTemplate) { + finalValue = { value, _source: Source.TEMPLATE }; + } else { + finalValue = { value, _source: Source.PERSISTENCE }; + } + } + + return atomStore.set(atom as any, finalValue); + }; + + if (state.settings) { + this._serializationFunctions.deserializeStateFunctions.settings?.(state.settings, persistedSetter); + } + + if (state.view) { + this._serializationFunctions.deserializeStateFunctions.view?.(state.view, persistedSetter); + } + } +} + +function debounce(fn: () => void, delay: number) { + let timeout: ReturnType | null = null; + return () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(fn, delay); + }; +} diff --git a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx index 93410f99a..1593f6b30 100644 --- a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx +++ b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx @@ -1,11 +1,12 @@ import { BugReport, Forum, WebAssetOff } from "@mui/icons-material"; +import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; import { ImportStatus, Module, ModuleCategory, ModuleDevState } from "@framework/Module"; import type { ModuleInstance } from "@framework/ModuleInstance"; import { Button } from "@lib/components/Button"; import { Tag } from "@lib/components/Tag"; -export class ModuleNotFoundPlaceholder extends Module { +export class ModuleNotFoundPlaceholder extends Module { constructor(moduleName: string) { super({ name: moduleName, @@ -16,8 +17,8 @@ export class ModuleNotFoundPlaceholder extends Module { this._importState = ImportStatus.Imported; } - makeInstance(id: string): ModuleInstance { - const instance = super.makeInstance(id); + makeInstance(id: string, atomStoreMaster: AtomStoreMaster): ModuleInstance { + const instance = super.makeInstance(id, atomStoreMaster); return instance; } diff --git a/frontend/src/framework/internal/NavigationObserver.ts b/frontend/src/framework/internal/NavigationObserver.ts new file mode 100644 index 000000000..45e62dd49 --- /dev/null +++ b/frontend/src/framework/internal/NavigationObserver.ts @@ -0,0 +1,57 @@ +export type NavigationObserverOptions = { + onBeforeUnload?: () => boolean; + onNavigate?: () => Promise; +}; + +export class NavigationObserver { + private readonly _options: NavigationObserverOptions; + + private _currentUrl: string = window.location.href; + private readonly handleBeforeUnloadBound = this.handleBeforeUnload.bind(this); + private readonly handlePopStateBound = this.handlePopState.bind(this); + + constructor(options: NavigationObserverOptions) { + this._options = options; + + window.addEventListener("beforeunload", this.handleBeforeUnloadBound); + window.addEventListener("popstate", this.handlePopStateBound); + } + + private async handleBeforeUnload(event: BeforeUnloadEvent) { + const { onBeforeUnload } = this._options; + if (!onBeforeUnload) { + return; + } + + const result = onBeforeUnload(); + + if (result) { + event.preventDefault(); + event.returnValue = ""; // This is necessary for the dialog to show in some browsers. + } + } + + private async handlePopState() { + const { onNavigate } = this._options; + if (!onNavigate) { + return; + } + + const previousUrl = this._currentUrl; + const result = await onNavigate(); + + if (!result) { + // If the navigation was not handled, we can prevent the default behavior. + // Note: This is a workaround, as popstate does not allow preventing default behavior. + // Instead, we can use a custom logic to handle the navigation. + window.history.pushState(null, "", previousUrl); + } else { + this._currentUrl = window.location.href; + } + } + + beforeDestroy(): void { + window.removeEventListener("beforeunload", this.handleBeforeUnloadBound); + window.removeEventListener("popstate", this.handlePopStateBound); + } +} diff --git a/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts b/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts index cef7d4923..d49c4c48c 100644 --- a/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts +++ b/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts @@ -1,6 +1,6 @@ import type { QueryClient } from "@tanstack/query-core"; -import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; +import { AtomStoreMaster } from "@framework/AtomStoreMaster"; import { EnsembleFingerprintStore } from "@framework/EnsembleFingerprintStore"; import { EnsembleSet } from "@framework/EnsembleSet"; import { EnsembleSetAtom, RealizationFilterSetAtom } from "@framework/GlobalAtoms"; @@ -18,7 +18,7 @@ import { } from "../EnsembleSetLoader"; import { PrivateWorkbenchSettings, type SerializedWorkbenchSettings } from "../PrivateWorkbenchSettings"; -import { type WorkbenchSessionDataContainer } from "./utils/WorkbenchSessionDataContainer"; +import { isPersisted, type WorkbenchSessionDataContainer } from "./utils/WorkbenchSessionDataContainer"; export type SerializedRegularEnsemble = { ensembleIdent: string; @@ -38,6 +38,15 @@ export type SerializedEnsembleSet = { deltaEnsembles: SerializedDeltaEnsemble[]; }; +export type WorkbenchSessionMetadata = { + title: string; + description?: string; + updatedAt: number; // Timestamp of the last modification + createdAt: number; // Timestamp of creation + hash?: string; // Optional hash for content integrity + lastModifiedMs: number; // Last modified timestamp for internal use +}; + export type WorkbenchSessionContent = { activeDashboardId: string | null; dashboards: SerializedDashboard[]; @@ -50,6 +59,9 @@ export enum PrivateWorkbenchSessionTopic { IS_ENSEMBLE_SET_LOADING = "EnsembleSetLoadingState", ACTIVE_DASHBOARD = "ActiveDashboard", DASHBOARDS = "Dashboards", + METADATA = "Metadata", + IS_PERSISTED = "IsPersisted", + IS_SNAPSHOT = "IsSnapshot", } export type WorkbenchSessionTopicPayloads = { @@ -58,11 +70,17 @@ export type WorkbenchSessionTopicPayloads = { [PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING]: boolean; [PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD]: Dashboard; [PrivateWorkbenchSessionTopic.DASHBOARDS]: Dashboard[]; + [PrivateWorkbenchSessionTopic.METADATA]: WorkbenchSessionMetadata; + [PrivateWorkbenchSessionTopic.IS_PERSISTED]: boolean; + [PrivateWorkbenchSessionTopic.IS_SNAPSHOT]: boolean; }; export class PrivateWorkbenchSession implements WorkbenchSession { private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _id: string | null = null; + private _isPersisted: boolean = false; + private _isSnapshot: boolean; private _atomStoreMaster: AtomStoreMaster; private _queryClient: QueryClient; private _dashboards: Dashboard[] = []; @@ -73,20 +91,86 @@ export class PrivateWorkbenchSession implements WorkbenchSession { filterSet: this._realizationFilterSet, }; private _userCreatedItems: UserCreatedItems; + private _metadata: WorkbenchSessionMetadata = { + title: "New Workbench Session", + createdAt: Date.now(), + updatedAt: Date.now(), + lastModifiedMs: Date.now(), + }; private _isEnsembleSetLoading: boolean = false; + private _loadedFromLocalStorage: boolean = false; private _settings: PrivateWorkbenchSettings = new PrivateWorkbenchSettings(); - private constructor(atomStoreMaster: AtomStoreMaster, queryClient: QueryClient) { - this._atomStoreMaster = atomStoreMaster; + private constructor(queryClient: QueryClient, isSnapshot = false) { + this._atomStoreMaster = new AtomStoreMaster(); this._queryClient = queryClient; - this._userCreatedItems = new UserCreatedItems(atomStoreMaster); + this._userCreatedItems = new UserCreatedItems(this._atomStoreMaster); this._atomStoreMaster.setAtomValue(RealizationFilterSetAtom, this._realizationFilterSet); + this._isSnapshot = isSnapshot; + } + + getIsLoadedFromLocalStorage(): boolean { + return this._loadedFromLocalStorage; + } + + setLoadedFromLocalStorage(loaded: boolean): void { + this._loadedFromLocalStorage = loaded; + } + + getAtomStoreMaster(): AtomStoreMaster { + return this._atomStoreMaster; + } + + getId(): string | null { + return this._id; + } + + setId(id: string): void { + if (this._id) throw new Error("Session ID already set"); + this._id = id; } getWorkbenchSettings(): PrivateWorkbenchSettings { return this._settings; } + isSnapshot(): boolean { + return this._isSnapshot; + } + + setIsSnapshot(isSnapshot: boolean): void { + this._isSnapshot = isSnapshot; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.IS_SNAPSHOT); + } + + getIsPersisted(): boolean { + return this._isPersisted; + } + + setIsPersisted(val: boolean): void { + this._isPersisted = val; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.IS_PERSISTED); + } + + getMetadata(): WorkbenchSessionMetadata { + return this._metadata; + } + + setMetadata(metadata: WorkbenchSessionMetadata): void { + this._metadata = metadata; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.METADATA); + } + + updateMetadata(update: Partial>, notify = true): void { + this._metadata = { ...this._metadata, ...update }; + + if (!notify) { + return; + } + + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.METADATA); + } + getContent(): WorkbenchSessionContent { return { activeDashboardId: this._activeDashboardId, @@ -114,6 +198,7 @@ export class PrivateWorkbenchSession implements WorkbenchSession { } async loadContent(content: WorkbenchSessionContent): Promise { + this._isPersisted = this._id !== null; this._activeDashboardId = content.activeDashboardId; this._dashboards = content.dashboards.map((s) => { const d = new Dashboard(this._atomStoreMaster); @@ -187,6 +272,12 @@ export class PrivateWorkbenchSession implements WorkbenchSession { return this.getActiveDashboard(); case PrivateWorkbenchSessionTopic.DASHBOARDS: return this._dashboards; + case PrivateWorkbenchSessionTopic.METADATA: + return this._metadata; + case PrivateWorkbenchSessionTopic.IS_PERSISTED: + return this._isPersisted; + case PrivateWorkbenchSessionTopic.IS_SNAPSHOT: + return this._isSnapshot; default: throw new Error(`No snapshot getter implemented for topic ${topic}`); } @@ -207,6 +298,17 @@ export class PrivateWorkbenchSession implements WorkbenchSession { return this._dashboards; } + setDashboards(dashboards: Dashboard[]): void { + this._dashboards = dashboards; + if (dashboards.length > 0) { + this._activeDashboardId = dashboards[0].getId(); + } else { + this._activeDashboardId = null; + } + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.DASHBOARDS); + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD); + } + getEnsembleSet(): EnsembleSet { return this._ensembleSet; } @@ -248,19 +350,25 @@ export class PrivateWorkbenchSession implements WorkbenchSession { } static async fromDataContainer( - atomStoreMaster: AtomStoreMaster, queryClient: QueryClient, dataContainer: WorkbenchSessionDataContainer, ): Promise { - const session = new PrivateWorkbenchSession(atomStoreMaster, queryClient); + const session = new PrivateWorkbenchSession(queryClient); + + if (isPersisted(dataContainer)) { + session.setId(dataContainer.id); + session.setIsPersisted(true); + session.setIsSnapshot(dataContainer.isSnapshot); + } + session.setMetadata(dataContainer.metadata); await session.loadContent(dataContainer.content); return session; } - static createEmpty(atomStoreMaster: AtomStoreMaster, queryClient: QueryClient): PrivateWorkbenchSession { - const session = new PrivateWorkbenchSession(atomStoreMaster, queryClient); + static createEmpty(queryClient: QueryClient): PrivateWorkbenchSession { + const session = new PrivateWorkbenchSession(queryClient); session.makeDefaultDashboard(); return session; } diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer.ts b/frontend/src/framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer.ts index b1a53ba36..8cedb98c5 100644 --- a/frontend/src/framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer.ts +++ b/frontend/src/framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer.ts @@ -4,12 +4,43 @@ import type { SerializedWorkbenchSession } from "./serialization"; export enum WorkbenchSessionSource { LOCAL_STORAGE = "localStorage", + BACKEND = "backend", } -export type WorkbenchSessionDataContainer = SerializedWorkbenchSession & { - id?: string; // Optional ID for the session (only when stored once), can be used to identify or restore the session - source: WorkbenchSessionSource.LOCAL_STORAGE; // Source of the session data -}; +export type WorkbenchSessionDataContainer = SerializedWorkbenchSession & + ( + | { + id?: string; // Optional ID for the session (only when stored once), can be used to identify or restore the session + source: WorkbenchSessionSource.LOCAL_STORAGE; // Source of the session data + } + | { + id: string; // Required ID for the session (when stored multiple times), used to identify or restore the session + source: WorkbenchSessionSource.BACKEND; // Source of the session data + isSnapshot: boolean; // Indicates if this session is a snapshot + } + ); + +export function isPersisted( + session: WorkbenchSessionDataContainer, +): session is Extract { + return typeof session.id === "string" && session.id.length > 0; +} + +export function isFromBackend( + session: WorkbenchSessionDataContainer, +): session is Extract { + return session.source === WorkbenchSessionSource.BACKEND; +} + +export function isSnapshot( + session: WorkbenchSessionDataContainer, +): session is Extract { + // Check if the session has the isSnapshot property set to true + if (session.source !== WorkbenchSessionSource.BACKEND) { + return false; // Only backend sessions can be snapshots + } + return session.isSnapshot === true; +} export function extractLayout(session: WorkbenchSessionDataContainer): LayoutElement[] { const activeDashboard = session.content.dashboards.find((d) => d.id === session.content.activeDashboardId); diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/crudHelpers.ts b/frontend/src/framework/internal/WorkbenchSession/utils/crudHelpers.ts new file mode 100644 index 000000000..18659fa69 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/utils/crudHelpers.ts @@ -0,0 +1,198 @@ +import type { InfiniteData, QueryClient } from "@tanstack/query-core"; + +import { + createSession, + createSnapshot, + getSessionMetadataQueryKey, + getSessionQueryKey, + getSessionsMetadataQueryKey, + getSnapshotsMetadataQueryKey, + updateSession, + type NewSession_api, + type PageSessionMetadata_api, + type PageSnapshotMetadata_api, + type Session_api, + type SessionUpdate_api, +} from "@api"; +import { FilterLevel, makeTanstackQueryFilters } from "@framework/utils/reactQuery"; + +export async function createSessionWithCacheUpdate( + queryClient: QueryClient, + sessionData: NewSession_api, +): Promise { + const response = await createSession({ + throwOnError: true, + body: sessionData, + }); + + // Invalidate the cache for the session to ensure the new session is fetched + queryClient.invalidateQueries({ + queryKey: getSessionsMetadataQueryKey(), + }); + + return response.data; +} + +export async function updateSessionAndCache( + queryClient: QueryClient, + sessionId: string, + sessionUpdate: SessionUpdate_api, +): Promise { + await updateSession({ + throwOnError: true, + path: { + session_id: sessionId, + }, + body: sessionUpdate, + }); + + // Invalidate the cache for the session to ensure the updated session is fetched + queryClient.invalidateQueries({ + queryKey: getSessionQueryKey({ path: { session_id: sessionId } }), + }); + queryClient.invalidateQueries({ + queryKey: getSessionsMetadataQueryKey(), + }); +} + +export async function createSnapshotWithCacheUpdate( + queryClient: QueryClient, + snapshotData: NewSession_api, +): Promise { + const response = await createSnapshot({ + throwOnError: true, + body: snapshotData, + }); + + // Invalidate the cache for the session to ensure the new session is fetched + queryClient.invalidateQueries({ + queryKey: getSnapshotsMetadataQueryKey(), + }); + + return response.data; +} + +export function removeSessionQueryData(queryClient: QueryClient, deletedSessionId: string) { + const sessionsListFilter = makeTanstackQueryFilters([getSessionsMetadataQueryKey()]); + const sessionsInfiniteListFilter = { queryKey: ["getSessionsMetadata", "infinite"] }; + + queryClient.setQueriesData(sessionsListFilter, function dropSessionFromList(page: PageSessionMetadata_api) { + if (!page) return undefined; + + const { pageToken, items } = page; + let dropped = false; + + const newItems = items.filter((session) => { + if (session.id !== deletedSessionId) return true; + + dropped = true; + return false; + }); + + if (dropped) return { pageToken, items: newItems }; + return undefined; + }); + + queryClient.setQueriesData( + sessionsInfiniteListFilter, + function dropSessionFromList(oldData: InfiniteData) { + if (!oldData) return undefined; + + const pageParams = oldData.pageParams; + const existingPages = oldData.pages; + + let dropped = false; + + const newPages = existingPages.map((page) => { + const { pageToken, items } = page; + + const newItems = items.filter((session) => { + if (session.id !== deletedSessionId) return true; + + dropped = true; + return false; + }); + + return { pageToken, items: newItems }; + }); + + if (dropped) return { pageParams, pages: newPages }; + return undefined; + }, + ); +} + +export function removeSnapshotQueryData(queryClient: QueryClient, deletedSnapshotId: string) { + const snapshotsListFilter = makeTanstackQueryFilters([getSnapshotsMetadataQueryKey()]); + const snapshotsInfiniteListFilter = { queryKey: ["getSnapshotsMetadata", "infinite"] }; + + queryClient.setQueriesData(snapshotsListFilter, function dropSnapshotFromList(page: PageSnapshotMetadata_api) { + if (!page) return undefined; + + const { pageToken, items } = page; + let dropped = false; + + const newItems = items.filter((snapshot) => { + if (snapshot.id !== deletedSnapshotId) return true; + + dropped = true; + return false; + }); + + if (dropped) return { pageToken, items: newItems }; + return undefined; + }); + + queryClient.setQueriesData( + snapshotsInfiniteListFilter, + function dropSnapshotFromList(oldData: InfiniteData) { + if (!oldData) return undefined; + + const pageParams = oldData.pageParams; + const existingPages = oldData.pages; + + let dropped = false; + + const newPages = existingPages.map((page) => { + const { pageToken, items } = page; + + const newItems = items.filter((snapshot) => { + if (snapshot.id !== deletedSnapshotId) return true; + + dropped = true; + return false; + }); + + return { pageToken, items: newItems }; + }); + + if (dropped) return { pageParams, pages: newPages }; + return undefined; + }, + ); +} + +export function replaceSessionQueryData(queryClient: QueryClient, newSession: Session_api) { + const sessionMetadataQueryKey = getSessionMetadataQueryKey({ path: { session_id: newSession.metadata.id } }); + const sessionQueryKey = getSessionQueryKey({ path: { session_id: newSession.metadata.id } }); + + const sessionsMetadataQueryKey = getSessionsMetadataQueryKey(); + // ! Something breaks when using hey-api's generated infinite query options: setQueriesData is unable to get the + // ! correct cache entry, and instead makes a new entry. getQueriesData still works as expected... + const sessionsMetadataInfiniteQueryKey = ["getSessionsMetadata", "infinite"]; + + // Replace query data that directly refers to this session + queryClient.setQueriesData(makeTanstackQueryFilters([sessionQueryKey], FilterLevel.PATH), newSession); + queryClient.setQueriesData( + makeTanstackQueryFilters([sessionMetadataQueryKey], FilterLevel.PATH), + newSession.metadata, + ); + + // Replace list data entries + // ! We sort these queries server-side; updating entries in these lists *might* result in an an incorrectly + // ! sorted state. We could theoretically make a filter that matches only unsorted lists, but the easier option + // ! is to just refetch all of them... + + queryClient.invalidateQueries(makeTanstackQueryFilters([sessionMetadataQueryKey, sessionsMetadataQueryKey])); + queryClient.invalidateQueries({ queryKey: sessionsMetadataInfiniteQueryKey }); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/hash.ts b/frontend/src/framework/internal/WorkbenchSession/utils/hash.ts index f7de2be5b..2136340d5 100644 --- a/frontend/src/framework/internal/WorkbenchSession/utils/hash.ts +++ b/frontend/src/framework/internal/WorkbenchSession/utils/hash.ts @@ -7,9 +7,13 @@ export function objectToJsonString(obj: unknown): string { } } -export async function hashJsonString(jsonString: string): Promise { +/* +This function computes a SHA-256 hash of the given string and returns it as a hex string. +NOTE: This function needs to be in sync with the backend implementation to ensure consistent hashing. +*/ +export async function hashSessionContentString(string: string): Promise { const encoder = new TextEncoder(); - const data = encoder.encode(jsonString); + const data = encoder.encode(string); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/loaders.ts b/frontend/src/framework/internal/WorkbenchSession/utils/loaders.ts index 0dcac2580..fe6e8311a 100644 --- a/frontend/src/framework/internal/WorkbenchSession/utils/loaders.ts +++ b/frontend/src/framework/internal/WorkbenchSession/utils/loaders.ts @@ -1,11 +1,41 @@ +import type { QueryClient } from "@tanstack/query-core"; + +import { getSessionOptions, getSnapshotOptions } from "@api"; + import { localStorageKeyForSessionId, WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX, WORKBENCH_SESSION_LOCAL_STORAGE_KEY_TEMP, } from "./localStorageHelpers"; -import { deserializeFromLocalStorage } from "./serialization"; +import { + deserializeSessionFromBackend, + deserializeFromLocalStorage, + deserializeSnapshotFromBackend, +} from "./serialization"; import type { WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; +export async function loadWorkbenchSessionFromBackend( + queryClient: QueryClient, + sessionId: string, +): Promise { + const sessionData = await queryClient.fetchQuery({ + ...getSessionOptions({ path: { session_id: sessionId } }), + }); + + return deserializeSessionFromBackend(sessionData); +} + +export async function loadSnapshotFromBackend( + queryClient: QueryClient, + snapshotId: string, +): Promise { + const snapshotData = await queryClient.fetchQuery({ + ...getSnapshotOptions({ path: { snapshot_id: snapshotId } }), + }); + + return deserializeSnapshotFromBackend(snapshotData); +} + export async function loadWorkbenchSessionFromLocalStorage( sessionId: string | null, ): Promise { diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/serialization.ts b/frontend/src/framework/internal/WorkbenchSession/utils/serialization.ts index 18187e34d..e71317004 100644 --- a/frontend/src/framework/internal/WorkbenchSession/utils/serialization.ts +++ b/frontend/src/framework/internal/WorkbenchSession/utils/serialization.ts @@ -1,16 +1,24 @@ import { Ajv } from "ajv/dist/jtd"; -import type { PrivateWorkbenchSession, WorkbenchSessionContent } from "../PrivateWorkbenchSession"; -import { workbenchSessionSchema } from "../workbenchSession.jtd"; +import type { Session_api, Snapshot_api } from "@api"; + +import type { + PrivateWorkbenchSession, + WorkbenchSessionContent, + WorkbenchSessionMetadata, +} from "../PrivateWorkbenchSession"; +import { workbenchSessionContentSchema, workbenchSessionSchema } from "../workbenchSession.jtd"; import { objectToJsonString } from "./hash"; import { sessionIdFromLocalStorageKey } from "./localStorageHelpers"; import { WorkbenchSessionSource, type WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; export type SerializedWorkbenchSession = { + metadata: WorkbenchSessionMetadata; content: WorkbenchSessionContent; }; const ajv = new Ajv(); +const validateContent = ajv.compile(workbenchSessionContentSchema); const validateFull = ajv.compile(workbenchSessionSchema); export function deserializeFromLocalStorage(key: string): WorkbenchSessionDataContainer | null { @@ -24,6 +32,7 @@ export function deserializeFromLocalStorage(key: string): WorkbenchSessionDataCo } const session: WorkbenchSessionDataContainer = { + metadata: parsed.metadata, content: parsed.content, id: sessionIdFromLocalStorageKey(key) ?? undefined, source: WorkbenchSessionSource.LOCAL_STORAGE, @@ -32,14 +41,68 @@ export function deserializeFromLocalStorage(key: string): WorkbenchSessionDataCo return session; } +export function deserializeSessionFromBackend(raw: Session_api): WorkbenchSessionDataContainer { + const parsed = JSON.parse(raw.content); + if (!validateContent(parsed)) { + throw new Error(`Backend session validation failed ${validateContent.errors}`); + } + + const session: WorkbenchSessionDataContainer = { + metadata: { + title: raw.metadata.title, + description: raw.metadata.description ?? undefined, + createdAt: new Date(raw.metadata.createdAt).getTime(), + updatedAt: new Date(raw.metadata.updatedAt).getTime(), + hash: raw.metadata.contentHash, + lastModifiedMs: new Date(raw.metadata.updatedAt).getTime(), // Fallback to now if not provided + }, + content: parsed, + id: raw.metadata.id, + source: WorkbenchSessionSource.BACKEND, + isSnapshot: false, + }; + + return session; +} + +export function deserializeSnapshotFromBackend(raw: Snapshot_api): WorkbenchSessionDataContainer { + const parsed = JSON.parse(raw.content); + if (!validateContent(parsed)) { + throw new Error(`Backend session validation failed ${validateContent.errors}`); + } + + const snapshot: WorkbenchSessionDataContainer = { + id: raw.metadata.id, + isSnapshot: true, + source: WorkbenchSessionSource.BACKEND, + metadata: { + title: raw.metadata.title, + description: raw.metadata.description ?? undefined, + createdAt: new Date(raw.metadata.createdAt).getTime(), + // Snapshots cannot be updated, so we use createdAt for both fields + updatedAt: new Date(raw.metadata.createdAt).getTime(), + hash: raw.metadata.contentHash, + lastModifiedMs: new Date().getTime(), // Fallback to now if not provided + }, + content: parsed, + }; + + return snapshot; +} + export function makeWorkbenchSessionStateString(session: PrivateWorkbenchSession): string { return objectToJsonString({ + metadata: { + title: session.getMetadata().title, + description: session.getMetadata().description, + }, content: session.getContent(), }); } export function makeWorkbenchSessionLocalStorageString(session: PrivateWorkbenchSession): string { return objectToJsonString({ + metadata: session.getMetadata(), content: session.getContent(), }); } diff --git a/frontend/src/framework/internal/WorkbenchSession/utils/url.ts b/frontend/src/framework/internal/WorkbenchSession/utils/url.ts new file mode 100644 index 000000000..5e35c8525 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/utils/url.ts @@ -0,0 +1,69 @@ +export function buildSessionUrl(sessionId: string): string { + const url = new URL(window.location.href); + + url.pathname = `/session/${sessionId}`; + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + return url.toString(); +} + +export function readSessionIdFromUrl(): string | null { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const sessionId = pathParts.includes("session") ? pathParts[pathParts.indexOf("session") + 1] : null; + + if (sessionId && /^[a-zA-Z0-9_-]{8}$/.test(sessionId)) { + return sessionId; + } + return null; +} + +export function removeSessionIdFromUrl(): void { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const sessionIndex = pathParts.indexOf("session"); + + if (sessionIndex === -1) { + return; + } + + url.pathname = "/"; // Reset to root if no snapshot ID is present + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + window.history.pushState({}, "", url.toString()); +} + +export function buildSnapshotUrl(snapshotId: string): string { + const url = new URL(window.location.href); + + url.pathname = `/snapshot/${snapshotId}`; + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + return url.toString(); +} + +export function readSnapshotIdFromUrl(): string | null { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const snapshotId = pathParts.includes("snapshot") ? pathParts[pathParts.indexOf("snapshot") + 1] : null; + + if (snapshotId && /^[a-zA-Z0-9_-]{8}$/.test(snapshotId)) { + return snapshotId; + } + return null; +} + +export function removeSnapshotIdFromUrl(): void { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const snapshotIndex = pathParts.indexOf("snapshot"); + + if (snapshotIndex === -1) { + return; + } + + url.pathname = "/"; // Reset to root if no snapshot ID is present + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + window.history.pushState({}, "", url.toString()); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts b/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts index 53d85d5ec..8206fe389 100644 --- a/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts +++ b/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts @@ -10,6 +10,7 @@ import type { SerializedEnsembleSet, SerializedRegularEnsemble, WorkbenchSessionContent, + WorkbenchSessionMetadata, } from "./PrivateWorkbenchSession"; import type { SerializedWorkbenchSession } from "./utils/serialization"; @@ -41,6 +42,19 @@ export const ensembleSetSchema: JTDSchemaType = { }, } as const; +export const workbenchSessionMetadataSchema: JTDSchemaType = { + properties: { + title: { type: "string" }, + createdAt: { type: "float64" }, + updatedAt: { type: "float64" }, + lastModifiedMs: { type: "float64" }, + }, + optionalProperties: { + description: { type: "string" }, + hash: { type: "string" }, + }, +} as const; + export const workbenchSessionContentSchema: JTDSchemaType = { properties: { activeDashboardId: { type: "string", nullable: true }, @@ -55,6 +69,7 @@ export const workbenchSessionContentSchema: JTDSchemaType = { properties: { + metadata: workbenchSessionMetadataSchema, content: workbenchSessionContentSchema, }, } as const; diff --git a/frontend/src/framework/internal/WorkbenchSessionPersistenceService.ts b/frontend/src/framework/internal/WorkbenchSessionPersistenceService.ts new file mode 100644 index 000000000..9cdc1eccc --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSessionPersistenceService.ts @@ -0,0 +1,518 @@ +import { toast } from "react-toastify"; + +import { getSessionMetadataOptions, getSessionsMetadataQueryKey } from "@api"; +import { DashboardTopic } from "@framework/internal/Dashboard"; +import { + PrivateWorkbenchSessionTopic, + type PrivateWorkbenchSession, +} from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { ModuleInstanceTopic } from "@framework/ModuleInstance"; +import { UserCreatedItemsEvent } from "@framework/UserCreatedItems"; +import type { Workbench } from "@framework/Workbench"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { WorkbenchSettingsTopic } from "@framework/WorkbenchSettings"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; + +import { + createSessionWithCacheUpdate, + createSnapshotWithCacheUpdate, + updateSessionAndCache, +} from "./WorkbenchSession/utils/crudHelpers"; +import { hashSessionContentString, objectToJsonString } from "./WorkbenchSession/utils/hash"; +import { localStorageKeyForSessionId } from "./WorkbenchSession/utils/localStorageHelpers"; +import { + makeWorkbenchSessionLocalStorageString, + makeWorkbenchSessionStateString, +} from "./WorkbenchSession/utils/serialization"; + +export type WorkbenchSessionPersistenceInfo = { + lastModifiedMs: number; + hasChanges: boolean; + lastPersistedMs: number | null; + backendLastUpdatedMs: number | null; +}; + +export enum WorkbenchSessionPersistenceServiceTopic { + PERSISTENCE_INFO = "PersistenceInfo", +} + +export type WorkbenchSessionPersistenceServiceTopicPayloads = { + [WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO]: WorkbenchSessionPersistenceInfo; +}; + +export class WorkbenchSessionPersistenceService + implements PublishSubscribe +{ + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _unsubscribeFunctionsManagerDelegate = new UnsubscribeFunctionsManagerDelegate(); + private _workbench: Workbench; + private _workbenchSession: PrivateWorkbenchSession | null = null; + private _lastPersistedHash: string | null = null; + private _currentHash: string | null = null; + private _currentStateString: string | null = null; + private _persistenceInfo: WorkbenchSessionPersistenceInfo = { + lastModifiedMs: 0, + hasChanges: false, + lastPersistedMs: null, + backendLastUpdatedMs: null, + }; + private _fetchingInterval: ReturnType | null = null; + private _pullDebounceTimeout: ReturnType | null = null; + private _pullInProgress = false; + private _pullCounter = 0; + + private _lastPersistedMs: number | null = null; + private _lastModifiedMs: number = 0; + private _backendLastUpdatedMs: number | null = null; + + constructor(workbench: Workbench) { + this._workbench = workbench; + } + + async setWorkbenchSession(session: PrivateWorkbenchSession) { + this.unsubscribeFromSessionUpdates(); + + this._workbenchSession = session; + + if (session.isSnapshot()) { + return; // No need to persist snapshots + } + + this._currentStateString = makeWorkbenchSessionStateString(this._workbenchSession); + this._currentHash = await hashSessionContentString(this._currentStateString); + this._lastPersistedMs = session.getMetadata().updatedAt; + this._lastModifiedMs = session.getMetadata().lastModifiedMs; + + if (!session.getIsLoadedFromLocalStorage()) { + this._lastPersistedHash = this._currentHash; + } else { + this._lastPersistedHash = null; + } + + this.updatePersistenceInfo(); + + this.subscribeToSessionChanges(); + this.subscribeToDashboardUpdates(); + this.subscribeToModuleInstanceUpdates(); + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + } + this._fetchingInterval = setInterval(() => { + this.repeatedlyFetchSessionFromBackend(); + }, 10000); // Fetch every 10 seconds + } + + async repeatedlyFetchSessionFromBackend() { + const queryClient = this._workbench.getQueryClient(); + if (!this._workbenchSession) { + return; + } + + const sessionId = this._workbenchSession.getId(); + if (!sessionId || !this._workbenchSession.getIsPersisted()) { + return; + } + + try { + const sessionBackendMetadata = await queryClient.fetchQuery({ + ...getSessionMetadataOptions({ + path: { session_id: sessionId }, + }), + }); + + this._backendLastUpdatedMs = new Date(sessionBackendMetadata.updatedAt).getTime(); + this.updatePersistenceInfo(); + } catch (error) { + console.error("Failed to fetch session from backend:", error); + } + } + + removeWorkbenchSession() { + if (!this._workbenchSession) { + return; + } + + this.removeFromLocalStorage(); + this.unsubscribeFromSessionUpdates(); + this.resetInternalState(); + this._workbenchSession = null; + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + } + } + + removeFromLocalStorage(): void { + const key = this.makeLocalStorageKey(); + localStorage.removeItem(key); + } + + getWorkbenchSession(): PrivateWorkbenchSession | null { + return this._workbenchSession; + } + + private subscribeToSessionChanges() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to changes."); + } + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.DASHBOARDS)(() => { + this.schedulePullFullSessionState(); + this.subscribeToDashboardUpdates(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSessionTopic.ENSEMBLE_SET)(() => { + this.schedulePullFullSessionState(); + this.subscribeToModuleInstanceUpdates(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSessionTopic.REALIZATION_FILTER_SET)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.METADATA)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getWorkbenchSettings() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSettingsTopic.SELECTED_COLOR_PALETTE_IDS)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getUserCreatedItems() + .subscribe(UserCreatedItemsEvent.INTERSECTION_POLYLINES_CHANGE, () => { + this.schedulePullFullSessionState(); + }), + ); + } + + hasChanges(): boolean { + return this._persistenceInfo.hasChanges; + } + + beforeDestroy(): void { + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); + } + + resetInternalState(): void { + this._lastPersistedHash = null; + this._currentHash = null; + this._currentStateString = null; + this._persistenceInfo = { + lastModifiedMs: 0, + hasChanges: false, + lastPersistedMs: null, + backendLastUpdatedMs: null, + }; + this._lastPersistedMs = null; + this._lastModifiedMs = 0; + + this.maybeClearPullDebounceTimeout(); + + this._pullCounter++; + this._pullInProgress = false; + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + this._fetchingInterval = null; + } + + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO); + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter( + topic: T, + ): () => WorkbenchSessionPersistenceServiceTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO) { + return this._persistenceInfo; + } + throw new Error(`Unknown topic: ${topic}`); + }; + + return snapshotGetter; + } + + private makeLocalStorageKey(): string { + if (!this._workbenchSession) { + throw new Error("Workbench is not set. Cannot create local storage key."); + } + + const sessionId = this._workbenchSession.getId(); + return localStorageKeyForSessionId(sessionId); + } + + private persistToLocalStorage() { + const key = this.makeLocalStorageKey(); + + if (this._workbenchSession) { + localStorage.setItem(key, makeWorkbenchSessionLocalStorageString(this._workbenchSession)); + } + } + + async makeSnapshot(title: string, description: string): Promise { + const queryClient = this._workbench.getQueryClient(); + + if (!this._workbenchSession) { + throw new Error("No active workbench session to make a snapshot of."); + } + + await this.pullFullSessionState({ immediate: true }); + const toastId = toast.loading("Creating snapshot..."); + + try { + const snapshotId = await createSnapshotWithCacheUpdate(queryClient, { + title, + description, + content: objectToJsonString(this._workbenchSession.getContent()), + }); + toast.dismiss(toastId); + toast.success("Snapshot successfully created."); + return snapshotId; + } catch (error) { + console.error("Failed to create snapshot:", error); + toast.dismiss(toastId); + toast.error("Failed to create snapshot. Please try again later."); + + return null; + } + } + + private maybeClearPullDebounceTimeout() { + if (this._pullDebounceTimeout) { + clearTimeout(this._pullDebounceTimeout); + this._pullDebounceTimeout = null; + } + } + + private schedulePullFullSessionState(delay: number = 200) { + this.maybeClearPullDebounceTimeout(); + + this._pullDebounceTimeout = setTimeout(() => { + this._pullDebounceTimeout = null; + this.pullFullSessionState(); + }, delay); + } + + private async pullFullSessionState({ immediate = false } = {}): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to pull state from."); + return false; + } + + if (this._pullInProgress && !immediate) { + // Do not allow concurrent pulls – let debounce handle retries + return false; + } + + this._pullInProgress = true; + const localPullId = ++this._pullCounter; + + try { + const oldHash = this._currentHash; + const newStateString = makeWorkbenchSessionStateString(this._workbenchSession); + const newHash = await hashSessionContentString(newStateString); + + // Only apply if it's still the latest pull + if (localPullId !== this._pullCounter) { + return false; + } + + if (newHash !== oldHash) { + this._currentStateString = newStateString; + this._currentHash = newHash; + this._lastModifiedMs = Date.now(); + + this._workbenchSession.updateMetadata({ lastModifiedMs: this._lastModifiedMs }, false); + this.persistToLocalStorage(); + this.updatePersistenceInfo(); + + return true; + } + return false; // No changes detected + } catch (error) { + console.error("Failed to pull full session state:", error); + return false; + } finally { + this._pullInProgress = false; + } + } + + async persistSessionState() { + const queryClient = this._workbench.getQueryClient(); + + if (!this._workbenchSession) { + throw new Error("No active workbench session to persist."); + } + + if (!this._currentStateString) { + throw new Error("Current state string is not set. Cannot persist session state."); + } + + // Make sure we pull the latest session before we save + this.maybeClearPullDebounceTimeout(); + await this.pullFullSessionState(); + + const metadata = this._workbenchSession.getMetadata(); + const id = this._workbenchSession.getId(); + const toastId = toast.loading("Persisting session state..."); + + if (this._currentHash === this._lastPersistedHash) { + toast.dismiss(toastId); + toast.info("No changes to persist."); + return; + } + + try { + if (this._workbenchSession.getIsPersisted()) { + if (!id) { + throw new Error("Session ID is not set. Cannot update session state."); + } + await updateSessionAndCache(queryClient, id, { + title: metadata.title, + description: metadata.description ?? null, + content: objectToJsonString(this._workbenchSession.getContent()), + }); + // On successful update, we can safely remove the local storage recovery entry + this.removeFromLocalStorage(); + + toast.dismiss(toastId); + toast.success("Session state updated successfully."); + } else { + const id = await createSessionWithCacheUpdate(queryClient, { + title: metadata.title, + description: metadata.description ?? null, + content: objectToJsonString(this._workbenchSession.getContent()), + }); + + // ! Make sure you remove the localStorage backup BEFORE you store the new session id + this.removeFromLocalStorage(); + + this._workbenchSession.setId(id); + toast.dismiss(toastId); + toast.success("Session successfully created and persisted."); + } + + // Reset queries to ensure the new session is fetched + queryClient.resetQueries({ queryKey: getSessionsMetadataQueryKey() }); + + this._lastPersistedMs = Date.now(); + this._lastPersistedHash = this._currentHash; + this._workbenchSession.setIsPersisted(true); + this.updatePersistenceInfo(); + } catch (error) { + console.error("Failed to persist session state:", error); + toast.dismiss(toastId); + toast.error("Failed to persist session state. Please try again later."); + } + } + + private updatePersistenceInfo() { + this._persistenceInfo = { + lastModifiedMs: this._lastModifiedMs, + hasChanges: this._currentHash !== this._lastPersistedHash, + lastPersistedMs: this._lastPersistedMs, + backendLastUpdatedMs: this._backendLastUpdatedMs, + }; + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO); + } + + private unsubscribeFromSessionUpdates() { + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); + } + + private subscribeToDashboardUpdates() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to dashboard updates."); + } + + this._unsubscribeFunctionsManagerDelegate.unsubscribe("dashboards"); + + const dashboards = this._workbenchSession.getDashboards(); + + for (const dashboard of dashboards) { + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.Layout)(() => { + this.schedulePullFullSessionState(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ModuleInstances)(() => { + this.schedulePullFullSessionState(); + this.subscribeToModuleInstanceUpdates(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ActiveModuleInstanceId)( + () => { + this.schedulePullFullSessionState(); + }, + ), + ); + } + } + + private subscribeToModuleInstanceUpdates() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to module instance updates."); + } + + this._unsubscribeFunctionsManagerDelegate.unsubscribe("module-instances"); + + const dashboards = this._workbenchSession.getDashboards(); + for (const dashboard of dashboards) { + const moduleInstances = dashboard.getModuleInstances(); + for (const moduleInstance of moduleInstances) { + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "module-instances", + moduleInstance.makeSubscriberFunction(ModuleInstanceTopic.SERIALIZED_STATE)(() => { + this.schedulePullFullSessionState(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "module-instances", + moduleInstance.makeSubscriberFunction(ModuleInstanceTopic.SYNCED_SETTINGS)(() => { + this.schedulePullFullSessionState(); + }), + ); + } + } + } +} diff --git a/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx new file mode 100644 index 000000000..d77494e42 --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx @@ -0,0 +1,104 @@ +import React from "react"; + +import { GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { loadAllWorkbenchSessionsFromLocalStorage } from "@framework/internal/WorkbenchSession/utils/loaders"; +import { + extractLayout, + type WorkbenchSessionDataContainer, +} from "@framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { Dialog } from "@lib/components/Dialog"; +import { timeAgo } from "@lib/utils/dates"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type ActiveSessionRecoveryDialogProps = { + workbench: Workbench; +}; + +export function ActiveSessionRecoveryDialog(props: ActiveSessionRecoveryDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.ActiveSessionRecoveryDialogOpen, + ); + + const activeSession = props.workbench.getWorkbenchSession(); + const isLoading = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsLoadingSession); + + const [session, setSession] = React.useState(null); + + const loadSession = React.useCallback( + async function loadSession() { + const loadedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + const storedSession = loadedSessions.find((s) => s.id === activeSession.getId()); + setSession(storedSession || null); + }, + [activeSession], + ); + + React.useEffect( + function loadSessionOnOpen() { + if (isOpen) { + loadSession(); + } + }, + [isOpen, loadSession], + ); + + if (!isOpen || !session) { + return null; + } + + function handleDiscard() { + props.workbench.discardLocalStorageSession(activeSession.getId(), false); + setIsOpen(false); + } + + function handleOpen() { + props.workbench.openSessionFromLocalStorage(activeSession.getId(), true); + } + + return ( + + + + + } + width={800} + > + We found an unsaved version of your current session in your local storage. You can either discard it or open + it to recover your work. +
+ +
+
+ Title + {session.metadata.title} +
+
+ Last modified + {timeAgo(Date.now() - session.metadata.lastModifiedMs)} +
+
+ Last persisted + {timeAgo(Date.now() - activeSession.getMetadata().lastModifiedMs)} +
+
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts new file mode 100644 index 000000000..0de43f12d --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts @@ -0,0 +1,2 @@ +export { ActiveSessionRecoveryDialog as UnsavedSessionChangesDialog } from "./activeSessionRecoveryDialog"; +export type { ActiveSessionRecoveryDialogProps as UnsavedSessionChangesDialogProps } from "./activeSessionRecoveryDialog"; diff --git a/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx b/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx index ba5f4dde8..daffcb0c0 100644 --- a/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx +++ b/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx @@ -5,9 +5,8 @@ import { useAtom } from "jotai"; import type { ModuleInterfaceTypes } from "@framework/Module"; import type { ModuleInstance } from "@framework/ModuleInstance"; - export type ApplyInterfaceEffectsProps = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; children?: React.ReactNode; }; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx index 6de1dcefc..6c58ede22 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx @@ -18,7 +18,7 @@ import { ChannelReceiverNode } from "./channelReceiverNode"; export type ChannelReceiverNodesWrapperProps = { forwardedRef: React.RefObject; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; }; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx index 8d652582a..28f19ec53 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/header.tsx @@ -33,7 +33,7 @@ export type HeaderProps = { workbench: Workbench; isMaximized?: boolean; isMinimized?: boolean; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; isDragged: boolean; onPointerDown?: (event: React.PointerEvent) => void; onReceiversClick?: (event: React.PointerEvent) => void; @@ -232,7 +232,7 @@ export const Header: React.FC = (props) => { type StatusIndicatorProps = { workbench: Workbench; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; isMinimized?: boolean; }; diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx index b3ba79f89..af6864244 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/viewContent.tsx @@ -20,18 +20,19 @@ import { CircularProgress } from "@lib/components/CircularProgress"; import { CrashView } from "./crashView"; type ViewContentProps = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; }; export const ViewContent = React.memo((props: ViewContentProps) => { + const workbenchSession = props.workbench.getWorkbenchSession(); const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); const moduleInstanceLifeCycleState = useModuleInstanceTopicValue( props.moduleInstance, ModuleInstanceTopic.LIFECYCLE_STATE, ); - const atomStore = props.workbench.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); + const atomStore = workbenchSession.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); const handleModuleInstanceReload = React.useCallback( function handleModuleInstanceReload() { diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx index 101829076..550369238 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx @@ -20,7 +20,7 @@ import { ViewContent } from "./private-components/viewContent"; type ViewWrapperProps = { isMaximized?: boolean; isMinimized?: boolean; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; width: number; height: number; diff --git a/frontend/src/framework/internal/components/Content/private-components/layout.tsx b/frontend/src/framework/internal/components/Content/private-components/layout.tsx index e2dd853cb..2cd844a27 100644 --- a/frontend/src/framework/internal/components/Content/private-components/layout.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/layout.tsx @@ -121,7 +121,8 @@ export const Layout: React.FC = (props) => { if (isNewModule && moduleName) { const layoutElement = currentLayout.find((el) => el.moduleInstanceId === pointerDownElementId); if (layoutElement) { - const instance = dashboard.makeAndAddModuleInstance(moduleName, layoutElement); + const instance = dashboard.makeAndAddModuleInstance(moduleName); + dashboard.setLayout(currentLayout); layoutElement.moduleInstanceId = instance.getId(); layoutElement.moduleName = instance.getName(); } @@ -373,7 +374,7 @@ export const Layout: React.FC = (props) => { rows = Math.ceil(minimizedLayouts.length / elementsPerRow); } - function computeModuleLayoutProps(moduleInstance: ModuleInstance) { + function computeModuleLayoutProps(moduleInstance: ModuleInstance) { const moduleId = moduleInstance.getId(); const layoutElement = layout.find((element) => element.moduleInstanceId === moduleId); diff --git a/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx b/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx new file mode 100644 index 000000000..df34dcac6 --- /dev/null +++ b/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx @@ -0,0 +1,171 @@ +import React from "react"; + +import { AddLink } from "@mui/icons-material"; + +import { GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { buildSnapshotUrl } from "@framework/internal/WorkbenchSession/utils/url"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { Dialog } from "@lib/components/Dialog"; +import { Input } from "@lib/components/Input"; +import { Label } from "@lib/components/Label"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type MakeSnapshotDialogProps = { + workbench: Workbench; +}; + +type MakeSnapshotDialogInputFeedback = { + title?: string; + description?: string; +}; + +export function CreateSnapshotDialog(props: MakeSnapshotDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.MakeSnapshotDialogOpen); + + const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsMakingSnapshot); + + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [snapshotUrl, setSnapshotUrl] = React.useState(null); + const [inputFeedback, setInputFeedback] = React.useState({}); + const inputRef = React.useRef(null); + + function handleCreateSnapshot() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + props.workbench + .makeSnapshot(title, description) + .then((snapshotId) => { + if (!snapshotId) { + return; + } + setTitle(""); + setDescription(""); + setInputFeedback({}); + if (!snapshotId) { + return; + } + setSnapshotUrl(buildSnapshotUrl(snapshotId)); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + } + + function handleCancel() { + setIsOpen(false); + setTitle(""); + setDescription(""); + setInputFeedback({}); + setSnapshotUrl(null); + } + + const layout = props.workbench.getWorkbenchSession().getActiveDashboard()?.getLayout() || []; + + let content: React.ReactNode = null; + let actions: React.ReactNode = null; + + React.useEffect( + function focusInput() { + if (isOpen && inputRef.current && !snapshotUrl) { + inputRef.current.focus(); + } + }, + [isOpen, snapshotUrl], + ); + + if (!snapshotUrl) { + content = ( +
+ +
+ + +
+
+ ); + + actions = ( + <> + + + + ); + } else { + content = ( +
+
Snapshot created successfully!
+
+ By sharing the following link you can give others access to your snapshot.
+ You can find all your created and visited snapshots in the snapshots dialog. +
+ navigator.clipboard.writeText(snapshotUrl || "")}> + Copy + + } + /> +
+ ); + + actions = ( + <> + + + ); + } + + return ( + + {content} + + ); +} diff --git a/frontend/src/framework/internal/components/CreateSnapshotDialog/index.ts b/frontend/src/framework/internal/components/CreateSnapshotDialog/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx b/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx new file mode 100644 index 000000000..0bb1930ff --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx @@ -0,0 +1,157 @@ +import React from "react"; + +import { GuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { WorkbenchTopic, type Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { Dialog } from "@lib/components/Dialog"; +import { CharLimitedInput } from "@lib/components/CharLimitedInput/charLimitedInput"; +import { Label } from "@lib/components/Label"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type EditSessionMetadataDialogProps = { + workbench: Workbench; + id: string | null; + title: string; + description?: string; + open: boolean; + onClose?: () => void; +}; + +type EditSessionDialogInputFeedback = { + title?: string; + description?: string; +}; + +export function EditSessionMetadataDialog(props: EditSessionMetadataDialogProps): React.ReactNode { + const hasActiveSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.HAS_ACTIVE_SESSION); + const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsSavingSession); + + const [title, setTitle] = React.useState(props.title); + const [description, setDescription] = React.useState(props.description ?? ""); + const [inputFeedback, setInputFeedback] = React.useState({}); + + const [prevTitle, setPrevTitle] = React.useState(props.title); + const [prevDescription, setPrevDescription] = React.useState(props.description ?? ""); + + if (prevTitle !== props.title) { + setPrevTitle(props.title); + setTitle(props.title); + } + + if (prevDescription !== props.description) { + setPrevDescription(props.description ?? ""); + setDescription(props.description ?? ""); + } + + function handleSave() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + if (hasActiveSession) { + const activeWorkbenchSession = props.workbench.getWorkbenchSession(); + if (activeWorkbenchSession && (activeWorkbenchSession.getId() === props.id || props.id === null)) { + props.workbench.getWorkbenchSession().updateMetadata({ title, description }); + props.workbench + .saveCurrentSession() + .then(() => { + setInputFeedback({}); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + return; + } + } + + if (props.id === null) { + console.error("Cannot update session metadata: session ID is null"); + return; + } + + props.workbench + .updateSession(props.id, { title, description }) + .then((result) => { + setInputFeedback({}); + if (result) { + props.onClose?.(); + } + }) + .catch((error) => { + console.error("Failed to update session metadata:", error); + }); + } + + function handleCancel() { + setInputFeedback({}); + props.onClose?.(); + } + + const layout = hasActiveSession + ? (props.workbench.getWorkbenchSession().getActiveDashboard().getLayout() ?? []) + : []; + + return ( + + + + + } + zIndex={60} + > +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts b/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts new file mode 100644 index 000000000..5ce99a09d --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts @@ -0,0 +1,2 @@ +export { EditSessionMetadataDialog } from "./editSessionMetadataDialog"; +export type { EditSessionMetadataDialogProps } from "./editSessionMetadataDialog"; diff --git a/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx b/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx index 7da323dcc..1fa9687e4 100644 --- a/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx +++ b/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx @@ -3,7 +3,7 @@ import React from "react"; import type { ModuleInstance } from "@framework/ModuleInstance"; export type Props = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; children?: React.ReactNode; }; diff --git a/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx b/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx new file mode 100644 index 000000000..768937f4d --- /dev/null +++ b/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { type ConfirmOptions, ConfirmationService } from "@framework/ConfirmationService"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; + +export function GlobalConfirmationDialog(): React.ReactNode { + const [visible, setVisible] = React.useState(false); + const [options, setOptions] = React.useState>(); + + React.useEffect(function onMount() { + ConfirmationService.setShowDialogCallback((options) => { + setOptions(options); + setVisible(true); + }); + }, []); + + function handleAction(actionId: string) { + ConfirmationService.resolve(actionId); + setVisible(false); + } + + if (!visible || !options) { + return null; + } + + return ( + + {options.actions.map((action) => ( + + ))} + + } + > + {options.message} + + ); +} diff --git a/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts b/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts new file mode 100644 index 000000000..208f905c4 --- /dev/null +++ b/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts @@ -0,0 +1 @@ +export { GlobalConfirmationDialog } from "./globalConfirmationDialog"; diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx index ec0ae69b5..7f3a48d0c 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx @@ -24,16 +24,14 @@ import { DebugProfiler } from "../../DebugProfiler"; import { HydrateQueryClientAtom } from "../../HydrateQueryClientAtom"; type ModuleSettingsProps = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; }; export const ModuleSettings: React.FC = (props) => { + const workbenchSession = props.workbench.getWorkbenchSession(); const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); - const dashboard = usePublishSubscribeTopicValue( - props.workbench.getWorkbenchSession(), - PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, - ); + const dashboard = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD); const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); @@ -41,7 +39,7 @@ export const ModuleSettings: React.FC = (props) => { props.moduleInstance, ModuleInstanceTopic.LIFECYCLE_STATE, ); - const atomStore = props.workbench.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); + const atomStore = workbenchSession.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); if (importState !== ImportStatus.Imported || !props.moduleInstance.isInitialized()) { return null; diff --git a/frontend/src/framework/internal/components/ModulesList/modulesList.tsx b/frontend/src/framework/internal/components/ModulesList/modulesList.tsx index d624c7155..d1db3c634 100644 --- a/frontend/src/framework/internal/components/ModulesList/modulesList.tsx +++ b/frontend/src/framework/internal/components/ModulesList/modulesList.tsx @@ -288,7 +288,7 @@ function makeDevStateIcon(devState: ModuleDevState): React.ReactNode { } type DetailsPopupProps = { - module: Module; + module: Module; right: number; top: number; onClose: () => void; diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts new file mode 100644 index 000000000..c6bc07e9a --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts @@ -0,0 +1,2 @@ +export { MultiSessionsRecoveryDialog } from "./multiSessionsRecoveryDialog"; +export type { MultiSessionsRecoveryDialogProps } from "./multiSessionsRecoveryDialog"; diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx new file mode 100644 index 000000000..58433bebf --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx @@ -0,0 +1,90 @@ +import React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { loadAllWorkbenchSessionsFromLocalStorage } from "@framework/internal/WorkbenchSession/utils/loaders"; +import type { WorkbenchSessionDataContainer } from "@framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; + +import { SessionRow } from "./private-components/sessionRow"; + +export type MultiSessionsRecoveryDialogProps = { + workbench: Workbench; +}; + +export function MultiSessionsRecoveryDialog(props: MultiSessionsRecoveryDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.MultiSessionsRecoveryDialogOpen, + ); + const [sessions, setSessions] = React.useState([]); + + async function loadSessions() { + const loadedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + setSessions(loadedSessions); + } + + React.useEffect( + function loadSessionOnOpen() { + if (isOpen) { + loadSessions(); + } + }, + [isOpen], + ); + + if (!isOpen) { + return null; + } + + function handleCancel() { + setIsOpen(false); + } + + function handleDiscard(sessionId: string | null) { + props.workbench.discardLocalStorageSession(sessionId); + loadSessions(); + } + + function handleOpen(sessionId: string | null) { + props.workbench.openSessionFromLocalStorage(sessionId); + } + + return ( + + + + } + width={800} + > + We found one or more previous sessions with unsaved changes. You can either discard them or open one of the + sessions below to recover your work. + + + + + + + + + + + + {sessions.map((session) => ( + + ))} + +
NameCreated AtUpdated atLast persistedActions
+
+ ); +} diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx new file mode 100644 index 000000000..5f7213c4d --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx @@ -0,0 +1,77 @@ +import type React from "react"; + +import { Delete, FileOpen } from "@mui/icons-material"; +import { useQuery } from "@tanstack/react-query"; + +import { getSessionMetadataOptions } from "@api"; +import { + isPersisted, + type WorkbenchSessionDataContainer, +} from "@framework/internal/WorkbenchSession/utils/WorkbenchSessionDataContainer"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export type SessionRowProps = { + session: WorkbenchSessionDataContainer; + onOpen: (sessionId: string | null) => void; + onDiscard: (sessionId: string | null) => void; +}; + +export function SessionRow(props: SessionRowProps): React.ReactNode { + const backendSession = useQuery({ + ...getSessionMetadataOptions({ + path: { session_id: props.session.id ?? "" }, + }), + enabled: Boolean(props.session.id) && isPersisted(props.session), + gcTime: 0, + staleTime: 0, + }); + + let lastPersisted: React.ReactNode = "Never"; + if (props.session.id) { + if (backendSession.isSuccess) { + const updatedAt = backendSession.data.updatedAt; + if (updatedAt) { + lastPersisted = new Date(updatedAt).toLocaleString(); + } + } else if (backendSession.isError) { + lastPersisted = "Failed to fetch metadata"; + } else if (backendSession.isFetching) { + lastPersisted = ( + + + Fetching from server... + + ); + } + } + + return ( + + + {props.session.metadata.title} + + {new Date(props.session.metadata.createdAt).toLocaleString()} + + {new Date(props.session.metadata.lastModifiedMs).toLocaleString()} + + {lastPersisted} + + + + + + ); +} diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/_utils.ts b/frontend/src/framework/internal/components/PersistenceManagementDialog/_utils.ts new file mode 100644 index 000000000..b80cedda9 --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/_utils.ts @@ -0,0 +1,21 @@ +export type EdsFilterRange = { from: Date | null; to: Date | null }; +export type FilterRange = { from?: string; to?: string }; + +export function edsRangeChoiceToFilterRange(edsRangeChoice: null | EdsFilterRange): undefined | FilterRange { + if (edsRangeChoice?.from || edsRangeChoice?.to) { + const filterRange: FilterRange = {}; + + if (edsRangeChoice.from) filterRange.from = edsRangeChoice.from.toISOString(); + if (edsRangeChoice.to) { + // The range component always uses hour 0 for the time + // We set the time to 23:59:59 to range inclusive + const toDate = new Date(edsRangeChoice.to); + toDate.setHours(23, 59, 59); + filterRange.to = toDate.toISOString(); + } + + return filterRange; + } + + return undefined; +} diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/constants.ts b/frontend/src/framework/internal/components/PersistenceManagementDialog/constants.ts new file mode 100644 index 000000000..5c21d5f6c --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/constants.ts @@ -0,0 +1,12 @@ +// - Infinite query behavior - --- --- --- --- +// To avoid jumpy loads, Page size should at the least be more than the visible amount of rows. +// CosmosDB has a max size of 100 by default +export const QUERY_PAGE_SIZE = 30; +export const NEXT_PAGE_THRESHOLD = 3; + +// - Table styling config ---- --- --- --- --- +// Table style config +export const USE_ALTERNATING_COLUMN_COLORS = false; +export const ROW_HEIGHT = 46; +export const HEADER_HEIGHT = 50; +export const TABLE_HEIGHT = ROW_HEIGHT * 10; diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/index.ts b/frontend/src/framework/internal/components/PersistenceManagementDialog/index.ts new file mode 100644 index 000000000..7d98e5d33 --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/index.ts @@ -0,0 +1 @@ +export { PersistenceManagementDialog } from "./persistenceManagementDialog"; diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/persistenceManagementDialog.tsx b/frontend/src/framework/internal/components/PersistenceManagementDialog/persistenceManagementDialog.tsx new file mode 100644 index 000000000..3668fec94 --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/persistenceManagementDialog.tsx @@ -0,0 +1,69 @@ +import type React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import type { Workbench } from "@framework/Workbench"; +import { WorkbenchTopic } from "@framework/Workbench"; +import { Dialog } from "@lib/components/Dialog"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { SessionManagementContent } from "./sessionManagementContent"; +import { SnapshotManagementContent } from "./snapshotManagementContent"; + +export type SessionOverviewDialogProps = { + workbench: Workbench; +}; + +export type ModalContentMode = "sessions" | "snapshots"; + +export function PersistenceManagementDialog(props: SessionOverviewDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogOpen, + ); + const hasActiveSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.HAS_ACTIVE_SESSION); + + const [contentMode, setContentMode] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogMode, + ); + + return ( + + + +
+ } + modal + open={isOpen && !hasActiveSession} + onClose={() => setIsOpen(false)} + width={1500} + showCloseCross + height={700} + > + {contentMode === "sessions" && } + {contentMode === "snapshots" && } + + ); +} diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/sessionManagementContent.tsx b/frontend/src/framework/internal/components/PersistenceManagementDialog/sessionManagementContent.tsx new file mode 100644 index 000000000..4100bf1f9 --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/sessionManagementContent.tsx @@ -0,0 +1,377 @@ +import React from "react"; + +import type { + GetSessionsMetadataData_api, + GetSessionsMetadataError_api, + GetSessionsMetadataResponse_api, + SessionMetadata_api, + SortDirection_api, +} from "@api"; +import { getSessionsMetadata, SessionSortBy_api } from "@api"; +import { DateRangePicker } from "@equinor/eds-core-react"; +import type { Workbench } from "@framework/Workbench"; +import type { Options } from "@hey-api/client-axios"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { Input } from "@lib/components/Input"; +import { Label } from "@lib/components/Label"; +import { Table } from "@lib/components/Table"; +import type { TableColumns, TableSorting } from "@lib/components/Table/types"; +import { SortDirection as TableSortDirection } from "@lib/components/Table/types"; +import { Tooltip } from "@lib/components/Tooltip"; +import { formatDate } from "@lib/utils/dates"; +import { Add, Close, Delete, Edit, FileOpen, Search } from "@mui/icons-material"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { InfiniteData } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; + +import { EditSessionMetadataDialog } from "../EditSessionMetadataDialog"; + +import { edsRangeChoiceToFilterRange } from "./_utils"; +import type { EdsFilterRange, FilterRange } from "./_utils"; +import { + QUERY_PAGE_SIZE, + NEXT_PAGE_THRESHOLD, + USE_ALTERNATING_COLUMN_COLORS, + ROW_HEIGHT, + TABLE_HEIGHT, + HEADER_HEIGHT, +} from "./constants"; + +type TableFilter = { + title?: string; + updatedAt?: FilterRange; +}; + +const TABLE_COLUMNS: TableColumns = [ + { + _type: "data", + columnId: "title", + label: "Title", + sizeInPercent: 20, + filter: false, + }, + { + _type: "data", + columnId: "description", + label: "Description", + sizeInPercent: 50, + filter: false, + sortable: false, + renderData(value) { + return value || N/A; + }, + }, + + // TODO - Future work: Could be nice to show/filter on modules used, but need backend changes and virtual table columns to support that + // { + // _type: "virtual", + // columnId: "modules" as keyof SessionMetadataWithId_api, + // label: "Modules", + // sizeInPercent: 15, + // filter: false, + // }, + { + _type: "data", + columnId: "updatedAt", + label: "Updated at", + sizeInPercent: 15, + filter: false, + formatValue: (value) => { + return formatDate(new Date(value)); + }, + }, + { + _type: "data", + columnId: "createdAt", + label: "Created at", + sizeInPercent: 15, + filter: false, + formatValue: (value) => { + return formatDate(new Date(value)); + }, + }, +]; + +function columnIdToApiSortField(columnId: string): SessionSortBy_api { + switch (columnId) { + case "title": + return SessionSortBy_api.METADATA_TITLE; + case "updatedAt": + return SessionSortBy_api.METADATA_UPDATED_AT; + case "createdAt": + return SessionSortBy_api.METADATA_CREATED_AT; + + default: + throw new Error(`Unknown columnId: ${columnId}`); + } +} + +function tableSortDirToApiSortDir(sort: TableSortDirection): SortDirection_api { + return sort as unknown as SortDirection_api; +} + +function useInfiniteSessionMetadataQuery(querySortParams: Options["query"]) { + // ! We need to manually write out the query because hey-api generates keys in a way that messes with Tanstack's + // ! ability to set query data (which we use after mutating metadata). + // ! You'd think this would work, but if I try this; the data never loads, because it tries to get the query + // ! params from the key... + // return useInfiniteQuery({ + // ...getSessionsMetadataInfiniteOptions({ + // query: { + // ...querySortParams, + // limit: QUERY_PAGE_SIZE, + // // // TODO: Rename `cursor` to `continuation_token` once we update to latest hey-api version + // // cursor: pageParam, + // }, + // }), + // queryKey: [ + // // @ts-expect-error -- Ignore expected tanstack key type + // "getSessionsMetadata", + // "infinite", + // querySortParams?.filter_title, + // querySortParams?.filter_updated_from, + // querySortParams?.filter_updated_to, + // querySortParams?.sort_by, + // querySortParams?.sort_direction, + // ], + // initialPageParam: null, + // refetchInterval: 20000, + // getNextPageParam(lastPage) { + // return lastPage.continuation_token; + // }, + // }); + + return useInfiniteQuery< + GetSessionsMetadataResponse_api, + AxiosError, + InfiniteData, + readonly unknown[], + string | null + >({ + queryKey: [ + "getSessionsMetadata", + "infinite", + querySortParams?.filter_title, + querySortParams?.filter_updated_from, + querySortParams?.filter_updated_to, + querySortParams?.sort_by, + querySortParams?.sort_direction, + ], + initialPageParam: null, + refetchInterval: 20000, + getNextPageParam(lastPage) { + return lastPage.pageToken; + }, + async queryFn({ pageParam, signal }) { + const { data } = await getSessionsMetadata({ + signal, + throwOnError: true, + query: { + ...querySortParams, + page_size: QUERY_PAGE_SIZE, + // TODO: Rename `cursor` to `continuation_token` once we update to latest hey-api version + cursor: pageParam, + }, + }); + + return data; + }, + }); +} + +export type SessionOverviewContentProps = { + workbench: Workbench; +}; + +export function SessionManagementContent(props: SessionOverviewContentProps): React.ReactNode { + const [editSessionDialogOpen, setEditSessionDialogOpen] = React.useState(false); + const [selectedSessionId, setSelectedSessionId] = React.useState(null); + const [deletePending, setDeletePending] = React.useState(false); + + const [visibleRowRange, setVisibleRowRange] = React.useState<{ start: number; end: number } | null>(null); + const [tableFilter, setTableFilter] = React.useState({}); + const [tableSortState, setTableSortState] = React.useState([ + { columnId: "updatedAt", direction: TableSortDirection.DESC }, + ]); + + const querySortParams = React.useMemo["query"]>(() => { + if (!tableSortState?.length) return undefined; + + const sortBy = columnIdToApiSortField(tableSortState[0].columnId); + const SortDirection = tableSortDirToApiSortDir(tableSortState[0].direction); + + return { + sort_by: sortBy, + sort_direction: SortDirection, + filter_title: tableFilter.title, + filter_updated_from: tableFilter.updatedAt?.from, + filter_updated_to: tableFilter.updatedAt?.to, + }; + }, [tableFilter, tableSortState]); + + const sessionsQuery = useInfiniteSessionMetadataQuery(querySortParams); + + const tableData = React.useMemo(() => { + if (!sessionsQuery.data) return []; + + return sessionsQuery.data.pages?.flatMap(({ items }) => items); + }, [sessionsQuery.data]); + + const onTableScrollIndexChange = React.useCallback((start: number, end: number) => { + setVisibleRowRange({ start, end }); + }, []); + + const selectedSession = React.useMemo(() => { + if (!selectedSessionId) return null; + return tableData.find((session) => session.id === selectedSessionId) || null; + }, [tableData, selectedSessionId]); + + React.useEffect( + function maybeRefetchNextPageEffect() { + if (!visibleRowRange || visibleRowRange.end === -1) return; + if (!sessionsQuery.hasNextPage) return; + if (sessionsQuery.isFetchingNextPage) return; + if (tableData.length - visibleRowRange?.end <= NEXT_PAGE_THRESHOLD) { + sessionsQuery.fetchNextPage(); + } + }, + [sessionsQuery, tableData.length, visibleRowRange], + ); + + function handleDateFilterRangeChange(newRange: null | EdsFilterRange) { + setTableFilter((prev) => { + return { + ...prev, + updatedAt: edsRangeChoiceToFilterRange(newRange), + }; + }); + } + + function handleTitleFilterValueChange(newValue: string) { + setTableFilter((prev) => { + return { + ...prev, + title: newValue || undefined, + }; + }); + } + + async function handleDeleteClick() { + if (!selectedSessionId) return; + + setDeletePending(true); + + const success = await props.workbench.deleteSession(selectedSessionId); + setDeletePending(false); + + if (!success) { + return; + } + + setSelectedSessionId(null); + } + + function handleEditClick() { + if (!selectedSessionId) return; + + setEditSessionDialogOpen(true); + } + + function handleOpenSessionClick() { + if (!selectedSessionId) return; + + props.workbench.openSession(selectedSessionId); + } + + function handleNewSessionClick() { + props.workbench.startNewSession(); + } + + return ( + <> +
+ + +
+
+ + + + + + + + + + + + + +
+ setSelectedSessionId(selection[0])} + onVisibleRowRangeChange={onTableScrollIndexChange} + noDataMessage="No sessions found." + /> + setEditSessionDialogOpen(false)} + /> + + ); +} diff --git a/frontend/src/framework/internal/components/PersistenceManagementDialog/snapshotManagementContent.tsx b/frontend/src/framework/internal/components/PersistenceManagementDialog/snapshotManagementContent.tsx new file mode 100644 index 000000000..8279eb964 --- /dev/null +++ b/frontend/src/framework/internal/components/PersistenceManagementDialog/snapshotManagementContent.tsx @@ -0,0 +1,398 @@ +import React from "react"; + +import type { GetSnapshotAccessLogsData_api, GraphUser_api, SnapshotAccessLog_api, SortDirection_api } from "@api"; +import { getSnapshotAccessLogsInfiniteOptions, getUserInfoOptions, SnapshotAccessLogSortBy_api } from "@api"; +import { DateRangePicker } from "@equinor/eds-core-react"; +import { buildSnapshotUrl } from "@framework/internal/WorkbenchSession/utils/url"; +import type { Workbench } from "@framework/Workbench"; +import type { Options } from "@hey-api/client-axios"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { Input } from "@lib/components/Input"; +import { Label } from "@lib/components/Label"; +import { Table } from "@lib/components/Table"; +import type { TableColumns, TableSorting, TContext } from "@lib/components/Table/types"; +import { SortDirection as TableSortDirection } from "@lib/components/Table/types"; +import { Tooltip } from "@lib/components/Tooltip"; +import { formatDate } from "@lib/utils/dates"; +import { Close, Delete, FileOpen, Search } from "@mui/icons-material"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { omit } from "lodash"; +import { toast } from "react-toastify"; + +import { UserAvatar } from "../UserAvatar"; + +import { edsRangeChoiceToFilterRange, type EdsFilterRange, type FilterRange } from "./_utils"; +import { + HEADER_HEIGHT, + NEXT_PAGE_THRESHOLD, + QUERY_PAGE_SIZE, + ROW_HEIGHT, + TABLE_HEIGHT, + USE_ALTERNATING_COLUMN_COLORS, +} from "./constants"; + +// The table comp doesn't support nested object key paths, so we transform the data into a flattened object +type FlattenedSnapshotAccessLog_api = Omit & { + [K in keyof SnapshotAccessLog_api["snapshotMetadata"] as `snapshotMetadata.${Extract}`]: SnapshotAccessLog_api["snapshotMetadata"][K]; +}; + +type TableFilter = { + title?: string; + visitedAt?: FilterRange; +}; + +const makeRowStyle = (context: TContext): React.CSSProperties => { + if (context.entry.snapshotDeleted) { + return { + textDecoration: "line-through", + opacity: 0.6, + }; + } + return {}; +}; + +const TABLE_COLUMNS: TableColumns = [ + { + _type: "data", + label: "Visits", + sizeInPercent: 7, + columnId: "visits", + sortable: false, // The sorting adornments require too much space, so the table looks off + filter: false, + formatStyle: (v, context) => ({ textAlign: "center", paddingRight: "0.5rem", ...makeRowStyle(context) }), + }, + { + _type: "data", + label: "Title", + sizeInPercent: 24, + columnId: "snapshotMetadata.title", + filter: false, + renderData(value, context) { + const style = makeRowStyle(context); + return ( + + {value} + {context.entry.snapshotDeleted && (deleted by owner)} + + ); + }, + }, + { + _type: "data", + columnId: "snapshotMetadata.description", + label: "Description", + sizeInPercent: 26, + filter: false, + sortable: false, + formatStyle: (value, context) => { + let style = makeRowStyle(context); + if (!value) { + style = { + ...style, + color: "gray", + fontStyle: "italic", + }; + } + return style; + }, + }, + { + // TODO: This too could be a "virtual" column + _type: "data", + columnId: "snapshotId", + label: "Url", + sortable: false, + filter: false, + sizeInPercent: 12, + renderData(snapshotId, context) { + const style = makeRowStyle(context); + const url = buildSnapshotUrl(snapshotId); + return ( + { + evt.preventDefault(); + navigator.clipboard.writeText(url); + toast.info("Url copied"); + }} + >{`/${snapshotId}`} + ); + }, + }, + { + _type: "data", + columnId: "snapshotMetadata.ownerId", + label: "Owner", + sortable: false, + filter: false, + sizeInPercent: 11, + renderData: function OwnerField(userId: string, context) { + const style = makeRowStyle(context); + const ownerInfo = useUserGraphInfo(userId); + const name = ownerInfo?.principal_name?.split("@")?.[0].toLocaleLowerCase(); + return ( +
+ + {name} +
+ ); + }, + }, + { + _type: "data", + label: "Last visited at", + sizeInPercent: 20, + columnId: "lastVisitedAt", + filter: false, + renderData(value) { + if (!value) return "N/A"; + return formatDate(new Date(value)); + }, + formatStyle: (value, context) => { + const style = makeRowStyle(context); + if (!value) { + return { + ...style, + color: "gray", + fontStyle: "italic", + }; + } + return style; + }, + }, +]; + +function useUserGraphInfo(ownerId: string | undefined): GraphUser_api | null { + const userInfoQuery = useQuery({ + ...getUserInfoOptions({ path: { user_id_or_email: ownerId ?? "" } }), + enabled: Boolean(ownerId), + }); + + return userInfoQuery.data ?? null; +} + +function columnIdToApiSortField(columnId: string): SnapshotAccessLogSortBy_api { + switch (columnId as keyof FlattenedSnapshotAccessLog_api) { + case "visits": + return SnapshotAccessLogSortBy_api.VISITS; + case "snapshotMetadata.title": + return SnapshotAccessLogSortBy_api.SNAPSHOT_METADATA_TITLE; + case "lastVisitedAt": + return SnapshotAccessLogSortBy_api.LAST_VISITED_AT; + case "snapshotMetadata.createdAt": + return SnapshotAccessLogSortBy_api.SNAPSHOT_METADATA_CREATED_AT; + + default: + throw new Error(`Unknown columnId: ${columnId}`); + } +} + +function tableSortDirToApiSortDir(sort: TableSortDirection): SortDirection_api { + return sort as unknown as SortDirection_api; +} + +export function flattenSnapshotAccessLogEntry(logEntry: SnapshotAccessLog_api): FlattenedSnapshotAccessLog_api { + const ret = omit(logEntry, ["snapshotMetadata"]) as Record; + + Object.entries(logEntry.snapshotMetadata).forEach(([k, v]) => { + const flattenedKey = `snapshotMetadata.${k}`; + ret[flattenedKey] = v; + }); + + return ret as FlattenedSnapshotAccessLog_api; +} + +export type SnapshotOverviewContentProps = { + workbench: Workbench; +}; + +export function SnapshotManagementContent(props: SnapshotOverviewContentProps): React.ReactNode { + const [selectedSnapshotId, setSelectedSnapshotId] = React.useState(null); + const [deletePending, setDeletePending] = React.useState(false); + + const [visibleRowRange, setVisibleRowRange] = React.useState<{ start: number; end: number } | null>(null); + const [tableFilter, setTableFilter] = React.useState({}); + const [tableSortState, setTableSortState] = React.useState([ + { columnId: "lastVisitedAt", direction: TableSortDirection.DESC }, + ]); + + const querySortParams = React.useMemo["query"]>(() => { + if (!tableSortState?.length) return undefined; + + const sortBy = columnIdToApiSortField(tableSortState[0].columnId); + const SortDirection = tableSortDirToApiSortDir(tableSortState[0].direction); + + return { + sort_by: sortBy, + sort_direction: SortDirection, + filter_title: tableFilter.title, + filter_updated_from: tableFilter.visitedAt?.from, + filter_updated_to: tableFilter.visitedAt?.to, + }; + }, [tableFilter, tableSortState]); + + const snapshotsQuery = useInfiniteQuery({ + ...getSnapshotAccessLogsInfiniteOptions({ + query: { ...querySortParams, page_size: QUERY_PAGE_SIZE }, + }), + // Tanstack requires initialPageParam. The correct option would be `null`, but that causes an + // undefined-error in `getVisitedSnapshotsInfiniteOptions(...)` since it thinks it's an object. + initialPageParam: "", + refetchInterval: 10000, + getNextPageParam(lastPage) { + return lastPage.pageToken; + }, + }); + + function onFilterRangeChange(newRange: null | EdsFilterRange) { + setTableFilter((prev) => { + return { + ...prev, + visitedAt: edsRangeChoiceToFilterRange(newRange), + }; + }); + } + + function handleTitleFilterValueChange(newValue: string) { + setTableFilter((prev) => { + return { + ...prev, + title: newValue || undefined, + }; + }); + } + + async function handleDeleteClick() { + if (!selectedSnapshotId) return; + + setDeletePending(true); + + const success = await props.workbench.deleteSnapshot(selectedSnapshotId); + setDeletePending(false); + + if (!success) { + return; + } + + setSelectedSnapshotId(null); + } + + function handleOpenSnapshotClick() { + if (!selectedSnapshotId) return; + + props.workbench.openSnapshot(selectedSnapshotId); + } + + const tableData = React.useMemo(() => { + if (!snapshotsQuery.data) return []; + + return snapshotsQuery.data?.pages?.flatMap(({ items }) => { + return items.map(flattenSnapshotAccessLogEntry); + }); + }, [snapshotsQuery.data]); + + const onTableScrollIndexChange = React.useCallback((start: number, end: number) => { + setVisibleRowRange({ start, end }); + }, []); + + const selectedSnapshot = React.useMemo(() => { + if (!selectedSnapshotId) return null; + return tableData.find((snapshot) => snapshot.snapshotId === selectedSnapshotId) || null; + }, [tableData, selectedSnapshotId]); + + React.useEffect( + function maybeRefetchNextPageEffect() { + if (!visibleRowRange || visibleRowRange.end === -1) return; + if (!snapshotsQuery.hasNextPage) return; + if (snapshotsQuery.isFetchingNextPage) return; + if (tableData.length - visibleRowRange?.end <= NEXT_PAGE_THRESHOLD) { + snapshotsQuery.fetchNextPage(); + } + }, + [snapshotsQuery, tableData.length, visibleRowRange], + ); + + const isSnapshotDeleted = selectedSnapshot?.snapshotDeleted ?? false; + + return ( + <> +
+ + + {/* TODO: Allow the user to filter on owner. Awaiting fixed picker comp, which I think is being done on the ensemble dialog */} + {/* */} + + +
+
+ + + + + + +
+
setSelectedSnapshotId(selection[0])} + /> + + ); +} diff --git a/frontend/src/framework/internal/components/RightNavBar/rightNavBar.tsx b/frontend/src/framework/internal/components/RightNavBar/rightNavBar.tsx index 7aeb02e34..1c83b1205 100644 --- a/frontend/src/framework/internal/components/RightNavBar/rightNavBar.tsx +++ b/frontend/src/framework/internal/components/RightNavBar/rightNavBar.tsx @@ -22,6 +22,10 @@ export const RightNavBar: React.FC = (props) => { guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent, ); + const [isTemplatesDialogOpen, setIsTemplatesDialogOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.TemplatesDialogOpen, + ); const numberOfUnsavedRealizationFilters = useGuiValue(guiMessageBroker, GuiState.NumberOfUnsavedRealizationFilters); const numberOfEffectiveRealizationFilters = useGuiValue( guiMessageBroker, @@ -54,7 +58,7 @@ export const RightNavBar: React.FC = (props) => { } function handleTemplatesListClick() { - togglePanelContent(RightDrawerContent.TemplatesList); + setIsTemplatesDialogOpen(true); } function handleRealizationFilterClick() { @@ -77,8 +81,8 @@ export const RightNavBar: React.FC = (props) => { onClick={handleModulesListClick} /> } onClick={handleTemplatesListClick} /> diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/moduleInstanceLog.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/moduleInstanceLog.tsx index 452f31e4a..1df664952 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/moduleInstanceLog.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/moduleInstanceLog.tsx @@ -179,7 +179,7 @@ export function ModuleInstanceLog(props: ModuleInstanceLogProps): React.ReactNod type LogListProps = { onShowDetails: (details: Record, yPos: number) => void; onHideDetails: () => void; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; }; function LogList(props: LogListProps): React.ReactNode { diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/templatesList.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/templatesList.tsx deleted file mode 100644 index c8b51fa5e..000000000 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/templatesList.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react"; - -import { GridView } from "@mui/icons-material"; - -import { GuiState, RightDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; -import { Drawer } from "@framework/internal/components/Drawer"; -import { ModuleRegistry } from "@framework/ModuleRegistry"; -import type { Template } from "@framework/TemplateRegistry"; -import { TemplateRegistry } from "@framework/TemplateRegistry"; -import type { Workbench } from "@framework/Workbench"; - -function drawTemplatePreview(template: Template, width: number, height: number): React.ReactNode { - return ( - - {template.moduleInstances.map((element, idx) => { - const w = element.layout.relWidth * width; - const h = element.layout.relHeight * height; - const x = element.layout.relX * width; - const y = element.layout.relY * height; - const strokeWidth = 2; - const headerHeight = 10; - const module = ModuleRegistry.getModule(element.moduleName); - const drawFunc = module.getDrawPreviewFunc(); - return ( - - - - - {element.moduleName} - - - {drawFunc && drawFunc(w - 4 * strokeWidth, h - headerHeight - 4 * strokeWidth)} - - - ); - })} - - ); -} - -type TemplatesListItemProps = { - templateName: string; - onClick: () => void; -}; - -const TemplatesListItem: React.FC = (props) => { - const ref = React.useRef(null); - const mainRef = React.useRef(null); - - const template = TemplateRegistry.getTemplate(props.templateName); - - return ( - <> -
-
- {template && drawTemplatePreview(template, 100, 100)} -
-
-
{props.templateName}
-
- {template?.description} -
-
-
- - ); -}; - -type TemplatesListProps = { - workbench: Workbench; - onClose?: () => void; -}; - -/* - @rmt: This component does probably need virtualization and therefore refactoring. - As this includes a lot more implementation, - I will skip it for now and come back to it when it becomes a problem. -*/ -export const TemplatesList: React.FC = (props) => { - const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.RightDrawerContent); - const [searchQuery, setSearchQuery] = React.useState(""); - - const handleSearchQueryChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }; - - const handleTemplateClick = (templateName: string) => { - const template = TemplateRegistry.getTemplate(templateName); - if (!template) { - return; - } - props.workbench.applyTemplate(template); - }; - - return ( - } - visible={drawerContent === RightDrawerContent.TemplatesList} - onClose={props.onClose} - > - {Object.keys(TemplateRegistry.getRegisteredTemplates()) - .filter((templName) => templName.toLowerCase().includes(searchQuery.toLowerCase())) - .map((templName) => ( - handleTemplateClick(templName)} - key={templName} - templateName={templName} - /> - ))} - - ); -}; diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx index 6cf89a48a..52320746c 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx @@ -10,7 +10,6 @@ import { ModulesList } from "../ModulesList"; import { ModuleInstanceLog } from "./private-components/moduleInstanceLog"; import { RealizationFilterSettings } from "./private-components/realizationFilterSettings"; -import { TemplatesList } from "./private-components/templatesList"; type RightSettingsPanelProps = { workbench: Workbench }; @@ -63,7 +62,6 @@ export const RightSettingsPanel: React.FC = (props) => - (""); + const [description, setDescription] = React.useState(""); + const [inputFeedback, setInputFeedback] = React.useState({}); + const inputRef = React.useRef(null); + + function handleSave() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + props.workbench.getWorkbenchSession().updateMetadata({ title, description }); + props.workbench + .saveCurrentSession(true) + .then(() => { + setTitle(""); + setDescription(""); + setInputFeedback({}); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + } + + function handleCancel() { + setIsOpen(false); + setTitle(""); + setDescription(""); + setInputFeedback({}); + } + + React.useEffect( + function focusInput() { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, + [isOpen], + ); + + const layout = props.workbench.getWorkbenchSession().getActiveDashboard()?.getLayout() || []; + + return ( + + + + + } + > +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/CaseExplorer/_utils.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/CaseExplorer/_utils.tsx index 0e146e92c..b9bb2e7a0 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/CaseExplorer/_utils.tsx +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/CaseExplorer/_utils.tsx @@ -68,7 +68,7 @@ export function makeCaseTableColumns( }, renderData: (value, context) => (
- + +
+
+
+ Start + + + + + + + + + +
+ +
+ Resources + + + Webviz on GitHub + + +
+ +
+
+ + ); +} diff --git a/frontend/src/framework/internal/components/StartPage/index.ts b/frontend/src/framework/internal/components/StartPage/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/StartPage/private-components/itemCard.tsx b/frontend/src/framework/internal/components/StartPage/private-components/itemCard.tsx new file mode 100644 index 000000000..27696a47d --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/itemCard.tsx @@ -0,0 +1,121 @@ +import React from "react"; + +import type { GraphUser_api } from "@api"; +import { getUserInfoOptions } from "@api"; +import { Tooltip } from "@lib/components/Tooltip"; +import { timeAgo } from "@lib/utils/dates"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { useQuery } from "@tanstack/react-query"; + +import { UserAvatar } from "../../UserAvatar"; + +export type ItemCardProps = { + id: string; + title: string; + timestamp: string; + description: string | null; + href: string; + ownerId?: string; + isDeleted?: boolean; + tooltipInfo?: Record; + onClick?: (id: string, evt: React.MouseEvent) => void; +}; + +export function ItemCard(props: ItemCardProps): React.ReactNode { + const showOwnerRow = props.ownerId; + + const ownerInfo = useUserGraphInfo(props.ownerId); + + const allTooltipInfo = React.useMemo(() => { + if (!ownerInfo) return props.tooltipInfo; + + return { + Author: ownerInfo?.display_name, + ...props.tooltipInfo, + }; + }, [ownerInfo, props.tooltipInfo]); + + function handleClick(evt: React.MouseEvent) { + if (props.isDeleted) { + evt.preventDefault(); + evt.stopPropagation(); + return; + } + props.onClick?.(props.id, evt); + } + + return ( + } + placement="left" + enterDelay="medium" + > + +
+ {props.title} +
+ {showOwnerRow && } + + ~ {timeAgo(Date.now() - new Date(props.timestamp).getTime())} + +
+
+ ); +} + +function OwnerLine(props: { owner: GraphUser_api | null }): React.ReactNode { + const name = props.owner?.principal_name?.split("@")?.[0].toLocaleLowerCase(); + + return ( +
+ + {name} +
+ ); +} + +function useUserGraphInfo(ownerId: string | undefined): GraphUser_api | null { + const userInfoQuery = useQuery({ + ...getUserInfoOptions({ path: { user_id_or_email: ownerId ?? "" } }), + enabled: Boolean(ownerId), + }); + + return userInfoQuery.data ?? null; +} + +// TODO: Show preview image here? +function TooltipContent( + props: { owner: GraphUser_api | null; tooltipInfo?: Record } & ItemCardProps, +): React.ReactNode { + if (props.isDeleted) { + return "This item has been deleted."; + } + return ( +
+

{props.title}

+
+ {props.description &&

{props.description}

} + {props.tooltipInfo && ( +
    + {Object.entries(props.tooltipInfo).map(([k, v]) => ( +
  • + {k}: {v} +
  • + ))} +
+ )} + Click to open +
+ ); +} diff --git a/frontend/src/framework/internal/components/StartPage/private-components/recentList.tsx b/frontend/src/framework/internal/components/StartPage/private-components/recentList.tsx new file mode 100644 index 000000000..144a63a45 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentList.tsx @@ -0,0 +1,132 @@ +import React from "react"; + +import { Icon, Typography } from "@equinor/eds-core-react"; +import { folder_open } from "@equinor/eds-icons"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { DenseIconButton } from "@lib/components/DenseIconButton"; +import { TimeAgo } from "@lib/components/TimeAgo/timeAgo"; +import { Tooltip } from "@lib/components/Tooltip"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Refresh } from "@mui/icons-material"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +Icon.add({ folder_open }); + +export type RecentListProps = { + title: string; + useQueryOptions: Omit, "refetchInterval">; + transformData: (data: TQueryData) => TItemType[]; + refetchIntervalMs?: number; + renderItem: (item: TItemType) => React.ReactNode; + makeItemKey: (item: TItemType) => string; + onDialogIconClick?: () => void; +}; + +export function RecentList( + props: RecentListProps, +): React.ReactNode { + const [isManualRefetch, setIsManualRefetch] = React.useState(false); + const [isRefreshAnimationPlaying, setIsRefreshAnimationPlaying] = React.useState(false); + const [lastUpdatedMs, setLastUpdatedMs] = React.useState(null); + + const itemsQuery = useQuery({ + ...props.useQueryOptions, + refetchInterval: props.refetchIntervalMs ?? 60000, + }); + + const isFirstTimeFetching = itemsQuery.status === "pending" || lastUpdatedMs === null; + + // Update lastUpdatedMs when query succeeds + React.useEffect( + function updateLastUpdatedTime() { + if (itemsQuery.isSuccess && itemsQuery.dataUpdatedAt > (lastUpdatedMs ?? 0)) { + setLastUpdatedMs(itemsQuery.dataUpdatedAt); + } + }, + [itemsQuery.isSuccess, itemsQuery.dataUpdatedAt, lastUpdatedMs], + ); + + // Handle manual refresh animation + React.useEffect( + function handleRefreshAnimation() { + if (isManualRefetch && !itemsQuery.isFetching) { + setIsManualRefetch(false); + setTimeout(function stopRefreshAnimation() { + setIsRefreshAnimationPlaying(false); + }, 900); + } + }, + [isManualRefetch, itemsQuery.isFetching], + ); + + function handleRefreshClick() { + if (itemsQuery.isFetching) { + return; + } + setIsManualRefetch(true); + setIsRefreshAnimationPlaying(true); + itemsQuery.refetch(); + } + + function makeContent() { + if (isFirstTimeFetching) { + if (itemsQuery.status === "pending") { + return ( + + Loading recent sessions... + + ); + } + + if (itemsQuery.status === "error") { + return Could not fetch recent sessions...; + } + } + + if (itemsQuery.status === "success" && itemsQuery.data) { + const transformedData = props.transformData(itemsQuery.data); + + if (transformedData.length === 0) { + return None found.; + } + return ( + <> +
    + {transformedData.map(function renderListItem(item) { + return
  • {props.renderItem(item)}
  • ; + })} +
+ + ); + } + } + + return ( +
+
+ + {props.title} + + + + + + + + + + + +
+ + Last updated: + +
{makeContent()}
+
+ ); +} diff --git a/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx b/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx new file mode 100644 index 000000000..4b8af2418 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx @@ -0,0 +1,68 @@ +import type React from "react"; + +import { getSessionsMetadataOptions, SortDirection_api, SessionSortBy_api, type SessionMetadata_api } from "@api"; +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { buildSessionUrl } from "@framework/internal/WorkbenchSession/utils/url"; +import type { Workbench } from "@framework/Workbench"; +import { timeAgo } from "@lib/utils/dates"; + +import { ItemCard } from "./itemCard"; +import { RecentList } from "./recentList"; + +export type RecentSessionsProps = { + workbench: Workbench; +}; + +export function RecentSessions(props: RecentSessionsProps) { + const [, setShowOverviewDialog] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogOpen, + ); + const [, setOverviewContentMode] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogMode, + ); + + function handleMoreClick() { + setOverviewContentMode("sessions"); + setShowOverviewDialog(true); + } + + function handleSessionClick(sessionId: string, evt: React.MouseEvent) { + evt.preventDefault(); + props.workbench.openSession(sessionId); + } + + return ( + data.items} + renderItem={(item: SessionMetadata_api) => ( + + )} + makeItemKey={(item: SessionMetadata_api) => item.id} + /> + ); +} diff --git a/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx b/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx new file mode 100644 index 000000000..eba1feae2 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx @@ -0,0 +1,77 @@ +import type React from "react"; + +import { + getSnapshotAccessLogsOptions, + SnapshotAccessLogSortBy_api, + SortDirection_api, + type SnapshotAccessLog_api, +} from "@api"; +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import type { Workbench } from "@framework/Workbench"; +import { timeAgo } from "@lib/utils/dates"; + +import { ItemCard } from "./itemCard"; +import { RecentList } from "./recentList"; + +export type RecentSnapshotsProps = { + workbench: Workbench; +}; + +export function RecentSnapshots(props: RecentSnapshotsProps): React.ReactNode { + const [, setShowOverviewDialog] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogOpen, + ); + const [, setOverviewContentMode] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.SessionSnapshotOverviewDialogMode, + ); + + function handleMoreClick() { + setOverviewContentMode("snapshots"); + setShowOverviewDialog(true); + } + + async function handleSnapshotClick(id: string, e: React.MouseEvent) { + props.workbench.openSnapshot(id); + e.preventDefault(); + } + + return ( +
+ data.items} + renderItem={(item: SnapshotAccessLog_api) => ( + + )} + makeItemKey={(item: SnapshotAccessLog_api) => item.snapshotId} + /> +
+ ); +} diff --git a/frontend/src/framework/internal/components/TemplatesDialog/index.ts b/frontend/src/framework/internal/components/TemplatesDialog/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx b/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx new file mode 100644 index 000000000..b71d96f6b --- /dev/null +++ b/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx @@ -0,0 +1,268 @@ +import React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { ModuleDataTags, type ModuleDataTagId } from "@framework/ModuleDataTags"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; +import { TemplateRegistry, type Template } from "@framework/TemplateRegistry"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; +import { Input } from "@lib/components/Input"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Search } from "@mui/icons-material"; + +export type TemplatesDialogProps = { + workbench: Workbench; +}; + +export function TemplatesDialog(props: TemplatesDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.TemplatesDialogOpen); + + const [searchQuery, setSearchQuery] = React.useState(""); + const [template, setTemplate] = React.useState