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,556 @@
1
+ """One edit, applied to a figure document.
2
+
3
+ Edits arrive as a list of named operations rather than as a replacement
4
+ document, for the reason ROI's do: "set the figure to this" cannot tell a
5
+ deliberate deletion from a client that lost half its state and autosaved
6
+ anyway, and it cannot be undone, replayed or reasoned about afterwards.
7
+ "remove pnl_7" can.
8
+
9
+ **One `apply` call is one undo step.** That is the contract the client's history
10
+ is built on, and it is why the batch operations exist at all: dragging a
11
+ five-panel selection is one `move_panels`, and Split Composite is one request
12
+ carrying `add_panel` x N plus `link_panels` -- so undoing a split is one
13
+ keystroke rather than five.
14
+
15
+ Every operation is applied to a copy and the batch is atomic: an invalid
16
+ operation anywhere leaves the stored document exactly as it was, rather than
17
+ half of a user's action landing.
18
+
19
+ The rules that live here rather than on the client, because the client's copy is
20
+ advice and this one is the rule:
21
+
22
+ * nothing is ever silently orphaned. Deleting a page says what happens to the
23
+ panels on it; deleting a source says what happens to the panels that
24
+ reference it. There is no default for either, because both possible defaults
25
+ are wrong -- destroying captured scenes because somebody tidied up a page, or
26
+ keeping panels that point at nothing and cannot be drawn;
27
+ * a panel must name a source that exists, and a placement must name a page that
28
+ exists;
29
+ * a link group needs at least two panels, since a group of one is a group whose
30
+ synchronisation has nothing to synchronise with.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import copy
36
+ from datetime import datetime, timezone
37
+
38
+ from plexora.plugins.figure_builder.server import schema
39
+
40
+ #: How many operations one request may carry. Split Composite of a 40-channel
41
+ #: panel is ~41; an import of a saved ROI category is one per region. A batch
42
+ #: past this is a client misbehaving, not a user working.
43
+ MAX_OPERATIONS = 2_000
44
+
45
+
46
+ def apply_operations(document, operations):
47
+ """Apply every operation in order, returning a new document.
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 not operations:
55
+ raise ValueError("no operations were sent")
56
+ if len(operations) > MAX_OPERATIONS:
57
+ raise ValueError(f"too many operations in one request (max {MAX_OPERATIONS})")
58
+
59
+ working = copy.deepcopy(document)
60
+ for operation in operations:
61
+ if not isinstance(operation, dict):
62
+ raise ValueError("each operation must be an object")
63
+ name = operation.get("op")
64
+ handler = _HANDLERS.get(name)
65
+ if handler is None:
66
+ raise ValueError(f"unknown operation {name!r}")
67
+ handler(working, operation)
68
+ return working
69
+
70
+
71
+ def now():
72
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
73
+
74
+
75
+ # -- document ------------------------------------------------------------
76
+
77
+
78
+ def _set_meta(document, op):
79
+ """Title and document-level settings.
80
+
81
+ Both on one operation because both are "the figure as a whole", and a
82
+ separate `set_title` would be a second way to do the same thing.
83
+ """
84
+ changes = op.get("changes")
85
+ if not isinstance(changes, dict):
86
+ raise ValueError("set_meta needs changes")
87
+ if "title" in changes:
88
+ title = schema.clean_text(changes["title"])
89
+ if not title:
90
+ raise ValueError("a figure needs a title")
91
+ document["title"] = title
92
+ if "settings" in changes:
93
+ merged = {**document["settings"], **(changes["settings"] or {})}
94
+ if isinstance(changes.get("settings"), dict) and isinstance(changes["settings"].get("style"), dict):
95
+ merged["style"] = {**document["settings"]["style"], **changes["settings"]["style"]}
96
+ document["settings"] = schema.normalize_settings(merged)
97
+
98
+
99
+ # -- pages ---------------------------------------------------------------
100
+
101
+
102
+ def _add_page(document, op):
103
+ page = schema.normalize_page(op.get("page") if isinstance(op.get("page"), dict) else {})
104
+ if _page_index(document, page["page_id"]) >= 0:
105
+ raise ValueError(f"page {page['page_id']!r} already exists")
106
+ if len(document["pages"]) >= schema.MAX_PAGES:
107
+ raise ValueError(f"a figure may hold at most {schema.MAX_PAGES} pages")
108
+ index = op.get("index")
109
+ if isinstance(index, int) and not isinstance(index, bool) and 0 <= index <= len(document["pages"]):
110
+ document["pages"].insert(index, page)
111
+ else:
112
+ document["pages"].append(page)
113
+
114
+
115
+ def _update_page(document, op):
116
+ index = _page_index(document, op.get("page_id"))
117
+ if index < 0:
118
+ raise ValueError(f"unknown page {op.get('page_id')!r}")
119
+ changes = op.get("changes")
120
+ if not isinstance(changes, dict):
121
+ raise ValueError("update_page needs changes")
122
+ merged = {**document["pages"][index], **changes, "page_id": document["pages"][index]["page_id"]}
123
+ document["pages"][index] = schema.normalize_page(merged)
124
+
125
+
126
+ def _remove_page(document, op):
127
+ """Delete a page, having been told what happens to what is on it.
128
+
129
+ `panels` is "delete" or "tray": destroy the captured scenes, or keep them
130
+ and unplace them. No default -- a page is deleted by one click and a
131
+ captured field may be the only record of a view somebody spent an hour
132
+ finding.
133
+ """
134
+ index = _page_index(document, op.get("page_id"))
135
+ if index < 0:
136
+ raise ValueError(f"unknown page {op.get('page_id')!r}")
137
+ if len(document["pages"]) <= 1:
138
+ raise ValueError("a figure needs at least one page")
139
+
140
+ disposition = op.get("panels")
141
+ if disposition not in ("delete", "tray"):
142
+ raise ValueError("remove_page needs panels='delete' or panels='tray'")
143
+
144
+ page_id = document["pages"][index]["page_id"]
145
+ doomed = [pid for pid, panel in document["panels"].items()
146
+ if panel["placement"] and panel["placement"]["page_id"] == page_id]
147
+ if disposition == "delete":
148
+ for panel_id in doomed:
149
+ _detach_panel(document, panel_id)
150
+ _detach_member(document, panel_id)
151
+ document["panels"].pop(panel_id, None)
152
+ else:
153
+ for panel_id in doomed:
154
+ document["panels"][panel_id]["placement"] = None
155
+ document["panels"][panel_id]["updated_at"] = now()
156
+
157
+ # Annotations belong to the page, not to the figure -- an arrow pointing at
158
+ # a panel that is gone has nothing to point at, so it goes with the page.
159
+ for annotation_id in [aid for aid, a in document["annotations"].items()
160
+ if a["page_id"] == page_id]:
161
+ _detach_member(document, annotation_id)
162
+ document["annotations"].pop(annotation_id, None)
163
+
164
+ document["pages"].pop(index)
165
+
166
+
167
+ def _reorder_pages(document, op):
168
+ order = op.get("page_ids")
169
+ if not isinstance(order, list):
170
+ raise ValueError("reorder_pages needs page_ids")
171
+ existing = {page["page_id"]: page for page in document["pages"]}
172
+ if sorted(order) != sorted(existing):
173
+ raise ValueError("reorder_pages must list every page exactly once")
174
+ document["pages"] = [existing[page_id] for page_id in order]
175
+
176
+
177
+ # -- sources -------------------------------------------------------------
178
+
179
+
180
+ def _add_source(document, op):
181
+ source = schema.normalize_source(op.get("source") if isinstance(op.get("source"), dict) else {})
182
+ if source["source_id"] in document["sources"]:
183
+ raise ValueError(f"source {source['source_id']!r} already exists")
184
+ if len(document["sources"]) >= schema.MAX_SOURCES:
185
+ raise ValueError(f"a figure may reference at most {schema.MAX_SOURCES} sources")
186
+ if source["kind"] == "plexora_project" and not source["datasource"]:
187
+ raise ValueError("a project source needs a datasource")
188
+ if source["kind"] == "imported_asset" and not source["asset_id"]:
189
+ raise ValueError("an imported source needs an asset_id")
190
+ document["sources"][source["source_id"]] = source
191
+
192
+
193
+ def _update_source(document, op):
194
+ source_id = op.get("source_id")
195
+ current = document["sources"].get(source_id)
196
+ if current is None:
197
+ raise ValueError(f"unknown source {source_id!r}")
198
+ changes = op.get("changes")
199
+ if not isinstance(changes, dict):
200
+ raise ValueError("update_source needs changes")
201
+ merged = {**current, **changes, "source_id": current["source_id"], "kind": current["kind"]}
202
+ document["sources"][source_id] = schema.normalize_source(merged)
203
+
204
+
205
+ def _remove_source(document, op):
206
+ """Stop referencing an image, having been told what happens to its panels.
207
+
208
+ `panels` is "delete" or "keep". "keep" leaves them in place as
209
+ cached-preview-only: they still draw, they still lay out, they simply
210
+ cannot be re-edited or re-rendered until the source is relinked. That is a
211
+ real answer, and often the right one for a figure that is finished.
212
+ """
213
+ source_id = op.get("source_id")
214
+ if source_id not in document["sources"]:
215
+ raise ValueError(f"unknown source {source_id!r}")
216
+ disposition = op.get("panels")
217
+ if disposition not in ("delete", "keep"):
218
+ raise ValueError("remove_source needs panels='delete' or panels='keep'")
219
+
220
+ if disposition == "delete":
221
+ for panel_id in [pid for pid, p in document["panels"].items()
222
+ if p["source_id"] == source_id]:
223
+ _detach_panel(document, panel_id)
224
+ _detach_member(document, panel_id)
225
+ document["panels"].pop(panel_id, None)
226
+ document["sources"].pop(source_id)
227
+
228
+
229
+ # -- panels --------------------------------------------------------------
230
+
231
+
232
+ def _add_panel(document, op):
233
+ raw = op.get("panel")
234
+ if not isinstance(raw, dict):
235
+ raise ValueError("add_panel needs a panel")
236
+ panel = schema.normalize_panel(raw)
237
+ if panel["panel_id"] in document["panels"]:
238
+ raise ValueError(f"panel {panel['panel_id']!r} already exists")
239
+ if len(document["panels"]) >= schema.MAX_PANELS:
240
+ raise ValueError(f"a figure may hold at most {schema.MAX_PANELS} panels")
241
+ _require_source(document, panel["source_id"])
242
+ if panel["placement"]:
243
+ _require_page(document, panel["placement"]["page_id"])
244
+
245
+ stamp = now()
246
+ panel["created_at"] = panel["created_at"] or stamp
247
+ panel["updated_at"] = stamp
248
+ # A group is joined through link_panels, never by asserting membership on
249
+ # the way in -- otherwise a panel can name a group that does not exist.
250
+ panel["link_group"] = None
251
+ document["panels"][panel["panel_id"]] = panel
252
+
253
+
254
+ def _update_panel(document, op):
255
+ panel_id = op.get("panel_id")
256
+ current = document["panels"].get(panel_id)
257
+ if current is None:
258
+ raise ValueError(f"unknown panel {panel_id!r}")
259
+ changes = op.get("changes")
260
+ if not isinstance(changes, dict):
261
+ raise ValueError("update_panel needs changes")
262
+
263
+ merged = {**current, **changes,
264
+ "panel_id": current["panel_id"],
265
+ # Group membership and lineage are set by their own operations.
266
+ # Letting a generic update touch them is how a panel ends up in a
267
+ # group the group has never heard of.
268
+ "link_group": current["link_group"],
269
+ "derived_from": current["derived_from"]}
270
+ if "source_id" in changes:
271
+ _require_source(document, schema.clean_id(changes["source_id"]))
272
+ updated = schema.normalize_panel(merged)
273
+ if updated["placement"]:
274
+ _require_page(document, updated["placement"]["page_id"])
275
+
276
+ # The render revision only ever goes forwards, and only the client that
277
+ # captured a new preview may advance it: a stale preview upload is refused
278
+ # by comparing against this number (see repository.put_preview), which only
279
+ # works while it is monotonic.
280
+ if updated["render_revision"] < current["render_revision"]:
281
+ updated["render_revision"] = current["render_revision"]
282
+ updated["created_at"] = current["created_at"]
283
+ updated["updated_at"] = now()
284
+ document["panels"][panel_id] = updated
285
+
286
+
287
+ def _move_panels(document, op):
288
+ """Reposition several panels at once.
289
+
290
+ One operation rather than N, because dragging a selection of five panels is
291
+ one thing the user did and must be one thing they can undo. Also what an
292
+ align or a distribute compiles to.
293
+ """
294
+ moves = op.get("moves")
295
+ if not isinstance(moves, list) or not moves:
296
+ raise ValueError("move_panels needs moves")
297
+ stamp = now()
298
+ for move in moves:
299
+ if not isinstance(move, dict):
300
+ raise ValueError("each move must be an object")
301
+ panel = document["panels"].get(move.get("panel_id"))
302
+ if panel is None:
303
+ raise ValueError(f"unknown panel {move.get('panel_id')!r}")
304
+ placement = move.get("placement")
305
+ if placement is None:
306
+ panel["placement"] = None
307
+ else:
308
+ if not isinstance(placement, dict):
309
+ raise ValueError("a move's placement must be an object or null")
310
+ merged = {**(panel["placement"] or {}), **placement}
311
+ panel["placement"] = schema.normalize_placement(merged)
312
+ _require_page(document, panel["placement"]["page_id"])
313
+ panel["updated_at"] = stamp
314
+
315
+
316
+ def _remove_panels(document, op):
317
+ ids = op.get("panel_ids")
318
+ if not isinstance(ids, list) or not ids:
319
+ raise ValueError("remove_panels needs panel_ids")
320
+ for panel_id in ids:
321
+ if panel_id not in document["panels"]:
322
+ raise ValueError(f"unknown panel {panel_id!r}")
323
+ for panel_id in ids:
324
+ _detach_panel(document, panel_id)
325
+ _detach_member(document, panel_id)
326
+ document["panels"].pop(panel_id, None)
327
+
328
+
329
+ # -- annotations ---------------------------------------------------------
330
+
331
+
332
+ def _add_annotation(document, op):
333
+ raw = op.get("annotation")
334
+ if not isinstance(raw, dict):
335
+ raise ValueError("add_annotation needs an annotation")
336
+ annotation = schema.normalize_annotation(raw)
337
+ if annotation["annotation_id"] in document["annotations"]:
338
+ raise ValueError(f"annotation {annotation['annotation_id']!r} already exists")
339
+ if len(document["annotations"]) >= schema.MAX_ANNOTATIONS:
340
+ raise ValueError(f"a figure may hold at most {schema.MAX_ANNOTATIONS} annotations")
341
+ _require_page(document, annotation["page_id"])
342
+ document["annotations"][annotation["annotation_id"]] = annotation
343
+
344
+
345
+ def _update_annotation(document, op):
346
+ annotation_id = op.get("annotation_id")
347
+ current = document["annotations"].get(annotation_id)
348
+ if current is None:
349
+ raise ValueError(f"unknown annotation {annotation_id!r}")
350
+ changes = op.get("changes")
351
+ if not isinstance(changes, dict):
352
+ raise ValueError("update_annotation needs changes")
353
+ if isinstance(changes.get("geometry"), dict):
354
+ changes = {**changes, "geometry": {**current["geometry"], **changes["geometry"]}}
355
+ if isinstance(changes.get("style"), dict):
356
+ changes = {**changes, "style": {**current["style"], **changes["style"]}}
357
+ merged = {**_words_base(current, changes), **changes,
358
+ "annotation_id": current["annotation_id"], "type": current["type"]}
359
+ updated = schema.normalize_annotation(merged)
360
+ _require_page(document, updated["page_id"])
361
+ document["annotations"][annotation_id] = updated
362
+
363
+
364
+ def _words_base(current, changes):
365
+ """`current`, minus whichever of `text`/`rich` the caller is not writing.
366
+
367
+ A text annotation holds its words twice: `rich` is the lines of styled runs
368
+ and `text` is the flat string derived from them. An update naming one of
369
+ them has to say what becomes of the other, and it cannot be a merge -- half
370
+ a line list means nothing.
371
+
372
+ * A caller writing `rich` is writing the words, so the stale `text` goes
373
+ and `normalize_annotation` derives it again.
374
+ * A caller writing only `text` does not know formatting exists -- an older
375
+ client, or a script -- so the `rich` it never saw goes with it. What it
376
+ asked to change was the words, and the words survive.
377
+
378
+ Without this the two fight silently: `{**current, **changes}` carrying a new
379
+ `text` beside an old `rich` re-derives `text` from `rich`, and the edit the
380
+ caller just made disappears with nothing to show it.
381
+ """
382
+ if "rich" in changes:
383
+ return {key: value for key, value in current.items() if key != "text"}
384
+ if "text" in changes:
385
+ return {key: value for key, value in current.items() if key != "rich"}
386
+ return current
387
+
388
+
389
+ def _remove_annotations(document, op):
390
+ ids = op.get("annotation_ids")
391
+ if not isinstance(ids, list) or not ids:
392
+ raise ValueError("remove_annotations needs annotation_ids")
393
+ for annotation_id in ids:
394
+ if annotation_id not in document["annotations"]:
395
+ raise ValueError(f"unknown annotation {annotation_id!r}")
396
+ for annotation_id in ids:
397
+ _detach_member(document, annotation_id)
398
+ document["annotations"].pop(annotation_id, None)
399
+
400
+
401
+ # -- linked groups -------------------------------------------------------
402
+
403
+
404
+ def _link_panels(document, op):
405
+ """Bind panels so an edit to one propagates to the others.
406
+
407
+ What propagates is `sync`, and it is deliberately a list rather than
408
+ all-or-nothing: a split-channel row shares a viewport and a size, and
409
+ emphatically does not share channels -- that is the entire point of it.
410
+ """
411
+ group = schema.normalize_link_group(op.get("group") if isinstance(op.get("group"), dict) else {})
412
+ if group["group_id"] in document["link_groups"]:
413
+ raise ValueError(f"group {group['group_id']!r} already exists")
414
+ for panel_id in group["panel_ids"]:
415
+ if panel_id not in document["panels"]:
416
+ raise ValueError(f"unknown panel {panel_id!r}")
417
+ if document["panels"][panel_id]["link_group"]:
418
+ raise ValueError(f"panel {panel_id!r} is already linked")
419
+ if len(set(group["panel_ids"])) < 2:
420
+ raise ValueError("a linked group needs at least two panels")
421
+
422
+ group["panel_ids"] = list(dict.fromkeys(group["panel_ids"]))
423
+ document["link_groups"][group["group_id"]] = group
424
+ for panel_id in group["panel_ids"]:
425
+ document["panels"][panel_id]["link_group"] = group["group_id"]
426
+
427
+
428
+ def _unlink_panels(document, op):
429
+ group_id = op.get("group_id")
430
+ group = document["link_groups"].get(group_id)
431
+ if group is None:
432
+ raise ValueError(f"unknown group {group_id!r}")
433
+ for panel_id in group["panel_ids"]:
434
+ panel = document["panels"].get(panel_id)
435
+ if panel is not None:
436
+ panel["link_group"] = None
437
+ document["link_groups"].pop(group_id)
438
+
439
+
440
+ # -- visual groups -------------------------------------------------------
441
+
442
+
443
+ def _group_items(document, op):
444
+ """Make several panels and annotations behave as one object.
445
+
446
+ A different thing from link_panels, which synchronises an EDIT between
447
+ split-channel panels. This synchronises nothing: it says that clicking any
448
+ member selects all of them and dragging any member drags all of them, which
449
+ is what "an image and its caption" needs and what a split row emphatically
450
+ does not.
451
+
452
+ A member may belong to one group only, and a member already in another is a
453
+ refusal rather than a silent move -- moving it would take it out of a group
454
+ the user cannot see from here.
455
+ """
456
+ group = schema.normalize_group(
457
+ op.get("group") if isinstance(op.get("group"), dict) else {})
458
+ if group["group_id"] in document["groups"]:
459
+ raise ValueError(f"group {group['group_id']!r} already exists")
460
+ if len(document["groups"]) >= schema.MAX_GROUPS:
461
+ raise ValueError(f"a figure may hold at most {schema.MAX_GROUPS} groups")
462
+
463
+ members = list(dict.fromkeys(group["member_ids"]))
464
+ if len(members) < 2:
465
+ raise ValueError("a group needs at least two members")
466
+ held = {member for existing in document["groups"].values()
467
+ for member in existing["member_ids"]}
468
+ for member_id in members:
469
+ if member_id not in document["panels"] and member_id not in document["annotations"]:
470
+ raise ValueError(f"unknown group member {member_id!r}")
471
+ if member_id in held:
472
+ raise ValueError(f"{member_id!r} is already in a group")
473
+
474
+ group["member_ids"] = members
475
+ document["groups"][group["group_id"]] = group
476
+
477
+
478
+ def _ungroup_items(document, op):
479
+ group_id = op.get("group_id")
480
+ if group_id not in document["groups"]:
481
+ raise ValueError(f"unknown group {group_id!r}")
482
+ document["groups"].pop(group_id)
483
+
484
+
485
+ # -- lookups and rules ---------------------------------------------------
486
+
487
+
488
+ def _page_index(document, page_id):
489
+ for index, page in enumerate(document["pages"]):
490
+ if page["page_id"] == page_id:
491
+ return index
492
+ return -1
493
+
494
+
495
+ def _require_page(document, page_id):
496
+ if _page_index(document, page_id) < 0:
497
+ raise ValueError(f"unknown page {page_id!r}")
498
+
499
+
500
+ def _require_source(document, source_id):
501
+ if source_id not in document["sources"]:
502
+ raise ValueError(f"unknown source {source_id!r}")
503
+
504
+
505
+ def _detach_panel(document, panel_id):
506
+ """Take a panel out of whatever group holds it, dissolving a group that
507
+ drops below two members -- a group of one synchronises with nothing."""
508
+ panel = document["panels"].get(panel_id)
509
+ group_id = panel and panel.get("link_group")
510
+ group = document["link_groups"].get(group_id) if group_id else None
511
+ if group is None:
512
+ return
513
+ group["panel_ids"] = [p for p in group["panel_ids"] if p != panel_id]
514
+ if len(group["panel_ids"]) < 2:
515
+ for remaining in group["panel_ids"]:
516
+ if remaining in document["panels"]:
517
+ document["panels"][remaining]["link_group"] = None
518
+ document["link_groups"].pop(group_id, None)
519
+
520
+
521
+ def _detach_member(document, member_id):
522
+ """Take a panel or an annotation out of whatever visual group holds it,
523
+ dissolving a group that drops below two members -- a group of one selects
524
+ one thing, which is what a click already does."""
525
+ for group_id, group in list(document.get("groups", {}).items()):
526
+ if member_id not in group["member_ids"]:
527
+ continue
528
+ group["member_ids"] = [m for m in group["member_ids"] if m != member_id]
529
+ if len(group["member_ids"]) < 2:
530
+ document["groups"].pop(group_id, None)
531
+ return
532
+
533
+
534
+ _HANDLERS = {
535
+ "set_meta": _set_meta,
536
+ "add_page": _add_page,
537
+ "update_page": _update_page,
538
+ "remove_page": _remove_page,
539
+ "reorder_pages": _reorder_pages,
540
+ "add_source": _add_source,
541
+ "update_source": _update_source,
542
+ "remove_source": _remove_source,
543
+ "add_panel": _add_panel,
544
+ "update_panel": _update_panel,
545
+ "move_panels": _move_panels,
546
+ "remove_panels": _remove_panels,
547
+ "add_annotation": _add_annotation,
548
+ "update_annotation": _update_annotation,
549
+ "remove_annotations": _remove_annotations,
550
+ "link_panels": _link_panels,
551
+ "unlink_panels": _unlink_panels,
552
+ "group_items": _group_items,
553
+ "ungroup_items": _ungroup_items,
554
+ }
555
+
556
+ OPERATION_NAMES = tuple(sorted(_HANDLERS))