plexora 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,293 @@
1
+ """One edit, applied to the annotation state.
2
+
3
+ Edits arrive as a list of named operations rather than as a replacement
4
+ document. That is not an optimization -- the store writes the whole blob either
5
+ way today -- it is what makes the write meaningful. "Set the state to this"
6
+ cannot tell a deliberate deletion from a client that lost half its state and
7
+ saved anyway, and it cannot be undone, replayed, or reasoned about after the
8
+ fact. "Delete roi_7" can.
9
+
10
+ Every operation is applied to a copy and the batch is atomic: an invalid
11
+ operation anywhere leaves the stored state exactly as it was, rather than half
12
+ of a user's action landing.
13
+
14
+ The rules that live here rather than on the client, because the client's copy is
15
+ advice and this one is the rule:
16
+
17
+ * a locked ROI (or one in a locked category) cannot have its geometry changed or
18
+ be deleted -- but CAN be renamed and recategorized, because the lock is on the
19
+ shape, not on what it means;
20
+ * two categories cannot share a label, since the label is how the user tells
21
+ them apart;
22
+ * every category is deletable and renameable, including the last one -- there is
23
+ no reserved catch-all, so no row of the list behaves differently from its
24
+ neighbours;
25
+ * deleting a category with shapes in it requires saying what happens to them,
26
+ and `reassign` requires naming the category they move to. There is no default
27
+ for either: silently deleting a user's regions because they tidied up a label
28
+ is the one outcome nobody wants, and silently keeping them under a category
29
+ that no longer exists is how you get shapes that cannot be drawn.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import copy
35
+ from datetime import datetime, timezone
36
+
37
+ from plexora.plugins.roi.server import schema
38
+ from plexora.plugins.roi.server.geometry import validate_geometry
39
+
40
+ #: How many operations one request may carry. An import arrives as a single
41
+ #: bulk operation rather than N creates, so a batch this long is a client
42
+ #: misbehaving.
43
+ MAX_OPERATIONS = 5_000
44
+
45
+
46
+ def apply_operations(state, operations):
47
+ """Apply every operation in order, returning a new state.
48
+
49
+ Raises ValueError -- with a message meant for the user -- if any of them is
50
+ invalid, having changed nothing.
51
+ """
52
+ if not isinstance(operations, list):
53
+ raise ValueError("operations must be a list")
54
+ if len(operations) > MAX_OPERATIONS:
55
+ raise ValueError(f"too many operations in one request (max {MAX_OPERATIONS})")
56
+
57
+ working = copy.deepcopy(state)
58
+ for operation in operations:
59
+ if not isinstance(operation, dict):
60
+ raise ValueError("each operation must be an object")
61
+ name = operation.get("op")
62
+ handler = _HANDLERS.get(name)
63
+ if handler is None:
64
+ raise ValueError(f"unknown operation {name!r}")
65
+ handler(working, operation)
66
+ return working
67
+
68
+
69
+ def now():
70
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
71
+
72
+
73
+ # -- categories ---------------------------------------------------------
74
+
75
+
76
+ def _category_create(state, op):
77
+ raw = op.get("category")
78
+ if not isinstance(raw, dict):
79
+ raise ValueError("category.create needs a category")
80
+ category = schema.normalize_category(raw)
81
+
82
+ _, existing = schema.find(state["categories"], category["id"])
83
+ if existing is not None:
84
+ raise ValueError(f"category {category['id']!r} already exists")
85
+ if len(state["categories"]) >= schema.MAX_CATEGORIES:
86
+ raise ValueError(f"a project may hold at most {schema.MAX_CATEGORIES} categories")
87
+ _require_unique_label(state, category["label"], category["id"])
88
+
89
+ state["categories"].append(category)
90
+
91
+
92
+ def _category_update(state, op):
93
+ category = _category(state, op.get("id"))
94
+ changes = op.get("changes")
95
+ if not isinstance(changes, dict):
96
+ raise ValueError("category.update needs changes")
97
+
98
+ if "label" in changes:
99
+ label = schema.clean_text(changes["label"])
100
+ if not label:
101
+ raise ValueError("a category needs a name")
102
+ _require_unique_label(state, label, category["id"])
103
+ category["label"] = label
104
+ if "color" in changes:
105
+ category["color"] = schema.color(changes["color"])
106
+ if "visible" in changes:
107
+ category["visible"] = bool(changes["visible"])
108
+ if "locked" in changes:
109
+ category["locked"] = bool(changes["locked"])
110
+ if "sort_order" in changes:
111
+ category["sort_order"] = schema.as_int(changes["sort_order"], category["sort_order"])
112
+
113
+
114
+ def _category_delete(state, op):
115
+ category = _category(state, op.get("id"))
116
+
117
+ orphans = op.get("orphans")
118
+ if orphans not in ("delete", "reassign"):
119
+ raise ValueError("category.delete needs orphans='delete' or orphans='reassign'")
120
+
121
+ if orphans == "reassign":
122
+ # Named explicitly, because there is no longer a catch-all to default
123
+ # to. A client that wants the shapes kept has to say where they go.
124
+ target_id = op.get("reassign_to")
125
+ if not target_id:
126
+ raise ValueError("category.delete with orphans='reassign' needs reassign_to")
127
+ target = _category(state, target_id)
128
+ if target["id"] == category["id"]:
129
+ raise ValueError("cannot reassign a category's ROIs to itself")
130
+
131
+ for entry in state["images"].values():
132
+ if orphans == "delete":
133
+ entry["features"] = [f for f in entry["features"]
134
+ if f["category_id"] != category["id"]]
135
+ else:
136
+ for feature in entry["features"]:
137
+ if feature["category_id"] == category["id"]:
138
+ feature["category_id"] = target["id"]
139
+ feature["updated_at"] = now()
140
+
141
+ index, _ = schema.find(state["categories"], category["id"])
142
+ state["categories"].pop(index)
143
+
144
+
145
+ # -- ROIs ---------------------------------------------------------------
146
+
147
+
148
+ def _roi_create(state, op):
149
+ entry = _image(state, op)
150
+ raw = op.get("feature")
151
+ if not isinstance(raw, dict):
152
+ raise ValueError("roi.create needs a feature")
153
+
154
+ feature = schema.normalize_feature(raw)
155
+ _, existing = schema.find(entry["features"], feature["id"])
156
+ if existing is not None:
157
+ raise ValueError(f"ROI {feature['id']!r} already exists")
158
+ if len(entry["features"]) >= schema.MAX_FEATURES:
159
+ raise ValueError(f"an image may hold at most {schema.MAX_FEATURES} ROIs")
160
+ _category(state, feature["category_id"])
161
+
162
+ stamp = now()
163
+ feature["created_at"] = feature["created_at"] or stamp
164
+ feature["updated_at"] = stamp
165
+ entry["features"].append(feature)
166
+
167
+
168
+ def _roi_update_geometry(state, op):
169
+ entry = _image(state, op)
170
+ feature = _feature(entry, op.get("id"))
171
+ _require_unlocked(state, feature, "move or reshape")
172
+
173
+ feature["geometry"] = validate_geometry(op.get("geometry"))
174
+ if "flags" in op:
175
+ feature["flags"] = schema.normalize_flags(op.get("flags"))
176
+ feature["updated_at"] = now()
177
+
178
+
179
+ def _roi_update_properties(state, op):
180
+ entry = _image(state, op)
181
+ feature = _feature(entry, op.get("id"))
182
+ changes = op.get("changes")
183
+ if not isinstance(changes, dict):
184
+ raise ValueError("roi.update_properties needs changes")
185
+
186
+ # Deliberately not gated on the lock: a locked shape can still be renamed
187
+ # and reclassified. The lock protects the geometry from an accidental drag,
188
+ # which is a different thing from freezing what the region means.
189
+ if "name" in changes:
190
+ feature["name"] = schema.clean_text(changes["name"])
191
+ if "notes" in changes:
192
+ feature["notes"] = schema.clean_text(changes["notes"], schema.MAX_NOTE_LENGTH)
193
+ if "category_id" in changes:
194
+ category = _category(state, changes["category_id"])
195
+ feature["category_id"] = category["id"]
196
+ if "locked" in changes:
197
+ feature["locked"] = bool(changes["locked"])
198
+ feature["updated_at"] = now()
199
+
200
+
201
+ def _roi_delete(state, op):
202
+ entry = _image(state, op)
203
+ feature = _feature(entry, op.get("id"))
204
+ _require_unlocked(state, feature, "delete")
205
+ index, _ = schema.find(entry["features"], feature["id"])
206
+ entry["features"].pop(index)
207
+
208
+
209
+ def _roi_bulk_delete(state, op):
210
+ entry = _image(state, op)
211
+ ids = op.get("ids")
212
+ if not isinstance(ids, list):
213
+ raise ValueError("roi.bulk_delete needs ids")
214
+ wanted = set()
215
+ for roi_id in ids:
216
+ feature = _feature(entry, roi_id)
217
+ _require_unlocked(state, feature, "delete")
218
+ wanted.add(feature["id"])
219
+ entry["features"] = [f for f in entry["features"] if f["id"] not in wanted]
220
+
221
+
222
+ def _roi_bulk_create(state, op):
223
+ """Many ROIs, and the categories they need, as one operation.
224
+
225
+ This is what an import is. Written as one operation rather than N so that
226
+ undoing it is one step: a user who imports 432 regions and changes their
227
+ mind wants one Ctrl+Z, not 432.
228
+ """
229
+ for raw in op.get("categories") or []:
230
+ category = schema.normalize_category(raw)
231
+ _, existing = schema.find(state["categories"], category["id"])
232
+ if existing is None:
233
+ _require_unique_label(state, category["label"], category["id"])
234
+ state["categories"].append(category)
235
+
236
+ for raw in op.get("features") or []:
237
+ _roi_create(state, {**op, "feature": raw})
238
+
239
+
240
+ # -- lookups and rules --------------------------------------------------
241
+
242
+
243
+ def _image(state, op):
244
+ key = op.get("image") or schema.DEFAULT_IMAGE
245
+ return schema.image_entry(state, key)
246
+
247
+
248
+ def _category(state, category_id):
249
+ _, category = schema.find(state["categories"], category_id)
250
+ if category is None:
251
+ raise ValueError(f"unknown category {category_id!r}")
252
+ return category
253
+
254
+
255
+ def _feature(entry, roi_id):
256
+ _, feature = schema.find(entry["features"], roi_id)
257
+ if feature is None:
258
+ raise ValueError(f"unknown ROI {roi_id!r}")
259
+ return feature
260
+
261
+
262
+ def _require_unlocked(state, feature, action):
263
+ if feature["locked"]:
264
+ raise ValueError(f"ROI {feature['id']!r} is locked and cannot be {action}d")
265
+ _, category = schema.find(state["categories"], feature["category_id"])
266
+ if category is not None and category["locked"]:
267
+ raise ValueError(
268
+ f"category {category['label']!r} is locked, so its ROIs cannot be {action}d"
269
+ )
270
+
271
+
272
+ def _require_unique_label(state, label, own_id):
273
+ """Two categories with the same name are two categories the user cannot
274
+ tell apart -- in the list, in the dropdown, and in every export."""
275
+ folded = label.casefold()
276
+ for category in state["categories"]:
277
+ if category["id"] != own_id and category["label"].casefold() == folded:
278
+ raise ValueError(f"a category named {label!r} already exists")
279
+
280
+
281
+ _HANDLERS = {
282
+ "category.create": _category_create,
283
+ "category.update": _category_update,
284
+ "category.delete": _category_delete,
285
+ "roi.create": _roi_create,
286
+ "roi.update_geometry": _roi_update_geometry,
287
+ "roi.update_properties": _roi_update_properties,
288
+ "roi.delete": _roi_delete,
289
+ "roi.bulk_delete": _roi_bulk_delete,
290
+ "roi.bulk_create": _roi_bulk_create,
291
+ }
292
+
293
+ OPERATION_NAMES = tuple(sorted(_HANDLERS))
@@ -0,0 +1,215 @@
1
+ """Where ROI annotations live, and who is allowed to overwrite whom.
2
+
3
+ Everything goes through `plexora.api.store`, whatever the project was imported
4
+ from. That is the decision the rest of the plugin rests on: an image-only
5
+ project, a CSV one and a SpatialData one all read and write the same document,
6
+ so drawing behaves identically and the source format only matters when the user
7
+ asks to export. Storing annotations in whichever object the project happened to
8
+ come with would mean every edit path had to understand every format.
9
+
10
+ The store writes an opaque blob per plugin per datasource -- no partial updates,
11
+ no revisions of its own. So the revision lives INSIDE the document and is
12
+ checked here, and this class exists so that stays true: routes never touch the
13
+ store, and a later move to row-level persistence changes this file only.
14
+
15
+ Why a revision at all, for what is nominally a single-user desktop app: two
16
+ browser tabs on the same project is not exotic, and neither is a tab left open
17
+ from yesterday. Both hold a full copy of the annotations and both autosave.
18
+ Last-writer-wins there means the stale tab's next save silently reinstates its
19
+ whole world -- deleting every region drawn in the other one, with no error and
20
+ nothing to notice. The check costs an integer comparison.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import threading
27
+
28
+ from plexora import api
29
+ from plexora.plugins.roi.server import operations, schema
30
+
31
+ PLUGIN_NAME = "roi"
32
+
33
+
34
+ class ConflictError(Exception):
35
+ """Somebody else wrote since the caller last read.
36
+
37
+ Carries the current revision so the client can say what happened rather
38
+ than just failing.
39
+ """
40
+
41
+ def __init__(self, current_revision):
42
+ super().__init__("ROI annotations changed in another session")
43
+ self.current_revision = current_revision
44
+
45
+
46
+ class ImageMismatch(Exception):
47
+ """The stored annotations were drawn on a different image than the one this
48
+ project now holds. See `ROIRepository.status`."""
49
+
50
+ def __init__(self, stored, current):
51
+ super().__init__("ROI annotations belong to a different image")
52
+ self.stored = stored
53
+ self.current = current
54
+
55
+
56
+ #: One lock per datasource, held across the read-modify-write in `apply`.
57
+ #: Plexora serves on waitress, which is multi-threaded, so two tabs saving at
58
+ #: the same moment really do land in two threads -- and without this they can
59
+ #: both read revision 41, both write revision 42, and one of them vanishes
60
+ #: without ever being told it conflicted. The revision check is only as good as
61
+ #: the atomicity of the section it guards.
62
+ _LOCKS: dict[str, threading.Lock] = {}
63
+ _LOCKS_GUARD = threading.Lock()
64
+
65
+
66
+ def _lock_for(datasource):
67
+ with _LOCKS_GUARD:
68
+ lock = _LOCKS.get(datasource)
69
+ if lock is None:
70
+ lock = _LOCKS[datasource] = threading.Lock()
71
+ return lock
72
+
73
+
74
+ class ROIRepository:
75
+ """One project's annotations."""
76
+
77
+ def __init__(self, datasource):
78
+ self.datasource = datasource
79
+ self._store = api.store(datasource, PLUGIN_NAME)
80
+
81
+ # -- reading --------------------------------------------------------
82
+
83
+ def image_size(self):
84
+ """(width, height) of the image these annotations are drawn on.
85
+
86
+ From the project record via the public dataset handle -- the same
87
+ numbers the viewer lays its tiles out with.
88
+ """
89
+ try:
90
+ return api.dataset(self.datasource).image.size
91
+ except KeyError:
92
+ return (None, None)
93
+
94
+ def load(self):
95
+ """The stored document, or the default one for a project with no ROIs.
96
+
97
+ The default is not persisted: opening the panel on a project nobody has
98
+ annotated should leave the project exactly as it found it.
99
+ """
100
+ width, height = self.image_size()
101
+ blob = self._store.get_state()
102
+ if not blob:
103
+ return schema.default_state(width, height)
104
+ try:
105
+ raw = json.loads(blob.decode("utf-8"))
106
+ except (UnicodeDecodeError, ValueError) as exc:
107
+ # Deliberately loud. The alternative -- quietly handing back an
108
+ # empty document -- presents "your annotations are gone" as "this
109
+ # project has no annotations", and the next autosave makes it true.
110
+ raise ValueError(f"stored ROI annotations could not be read: {exc}") from exc
111
+ return schema.normalize_state(raw, width, height)
112
+
113
+ def status(self, state=None):
114
+ """What the client needs before it draws anything.
115
+
116
+ `dimension_mismatch` is the guard against a datasource whose image has
117
+ been swapped underneath it. Regions from the old slide will render
118
+ perfectly happily on the new one, in the wrong places, and look right --
119
+ which is why this is reported rather than shrugged off, and why the
120
+ client draws nothing until it is resolved.
121
+ """
122
+ state = self.load() if state is None else state
123
+ width, height = self.image_size()
124
+ stored = (state.get("images", {}).get(schema.DEFAULT_IMAGE, {})
125
+ .get("coordinate_space") or {})
126
+ stored_size = (stored.get("width"), stored.get("height"))
127
+ has_features = any(entry.get("features")
128
+ for entry in state.get("images", {}).values())
129
+ mismatch = bool(
130
+ has_features
131
+ and all(stored_size)
132
+ and all((width, height))
133
+ and stored_size != (width, height)
134
+ )
135
+ return {
136
+ "image_size": [width, height],
137
+ "stored_image_size": list(stored_size),
138
+ "dimension_mismatch": mismatch,
139
+ }
140
+
141
+ def destination(self, state=None):
142
+ """Where this project last exported to, or "" if it never has.
143
+
144
+ Not folded into `status()`: that runs on every apply, and this is only
145
+ ever wanted by the one route that also has to list what names are
146
+ already taken in the user's file.
147
+ """
148
+ state = self.load() if state is None else state
149
+ return state.get("settings", {}).get("destination", "")
150
+
151
+ # -- writing --------------------------------------------------------
152
+
153
+ def apply(self, base_revision, ops):
154
+ """Apply operations if the caller was up to date, and bump the revision.
155
+
156
+ Returns the new revision. Raises ConflictError when somebody else wrote
157
+ first, ValueError when an operation is invalid (nothing is stored in
158
+ either case).
159
+ """
160
+ if not isinstance(base_revision, int) or isinstance(base_revision, bool):
161
+ raise ValueError("base_revision must be an integer")
162
+
163
+ with _lock_for(self.datasource):
164
+ state = self.load()
165
+ if state["revision"] != base_revision:
166
+ raise ConflictError(state["revision"])
167
+ self._require_matching_image(state)
168
+
169
+ updated = operations.apply_operations(state, ops)
170
+ updated["revision"] = state["revision"] + 1
171
+ self._write(updated)
172
+ return updated["revision"]
173
+
174
+ def replace(self, base_revision, state):
175
+ """Store a whole document, for the paths that build one rather than edit
176
+ one (import). Same revision check, same lock."""
177
+ with _lock_for(self.datasource):
178
+ current = self.load()
179
+ if current["revision"] != base_revision:
180
+ raise ConflictError(current["revision"])
181
+ state = dict(state)
182
+ state["revision"] = current["revision"] + 1
183
+ self._write(state)
184
+ return state["revision"]
185
+
186
+ def remember_destination(self, name):
187
+ """Record where this project's annotations were last exported to.
188
+
189
+ Deliberately does NOT bump the revision. The revision answers "have the
190
+ annotations changed under me", and every open tab acts on the answer --
191
+ bumping it here would greet a colleague's second tab with a conflict
192
+ banner because somebody chose a filename. Nothing about the shapes has
193
+ changed, so no client is stale.
194
+
195
+ Still inside the lock: it is a read-modify-write of the same blob an
196
+ `apply` writes, and the two interleaving would lose one of them.
197
+
198
+ Only reachable after a successful native export, which needs regions,
199
+ which needs a stored document -- so this never conjures a blob for a
200
+ project nobody has annotated.
201
+ """
202
+ with _lock_for(self.datasource):
203
+ state = self.load()
204
+ state.setdefault("settings", schema.default_settings())
205
+ state["settings"]["destination"] = schema.clean_text(name)
206
+ self._write(state)
207
+ return state["settings"]["destination"]
208
+
209
+ def _write(self, state):
210
+ self._store.put_state(json.dumps(state, separators=(",", ":")).encode("utf-8"))
211
+
212
+ def _require_matching_image(self, state):
213
+ report = self.status(state)
214
+ if report["dimension_mismatch"]:
215
+ raise ImageMismatch(report["stored_image_size"], report["image_size"])