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,594 @@
1
+ """Figure Builder's HTTP surface.
2
+
3
+ Thin on purpose, like ROI's: parse, hand to the repository, translate the
4
+ outcome. Every rule about what a figure is lives in `schema`/`operations`, and
5
+ every rule about who may overwrite whom lives in `repository`, so there is one
6
+ place to read each and no route can quietly disagree with another.
7
+
8
+ Status codes carry meaning, because the client acts on them differently:
9
+
10
+ 400 the request is wrong -- an unknown operation, a panel naming a page
11
+ that does not exist. Retrying it unchanged will fail the same way.
12
+ 404 no such figure. Distinct from 400 because the library shows it
13
+ differently: a figure that has been deleted is not a bad request.
14
+ 409 the request was fine but somebody else wrote first. The client has
15
+ work worth keeping, so this opens the conflict prompt rather than
16
+ discarding either side.
17
+ 422 the stored figure cannot be read by this build -- damaged, or written
18
+ by a newer Plexora. Nothing is written and nothing is drawn.
19
+
20
+ One route here is a PAGE rather than an API: `/figure/<id>` renders the
21
+ workspace for a figure opened without a project. A figure spans datasources, so
22
+ requiring one before it can be opened would make "open my figure" mean "guess
23
+ which of its four images you meant".
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from flask import Blueprint, Response, jsonify, render_template, request, send_file
29
+
30
+ from plexora import api, app
31
+ from plexora.plugins.figure_builder import PLUGIN, VERSION
32
+ from plexora.plugins.figure_builder.server import pixels, render, repository, schema, sources
33
+ from plexora.plugins.figure_builder.server.repository import ConflictError, UnknownFigure
34
+
35
+ figure_builder_bp = Blueprint(
36
+ 'figure_builder', __name__,
37
+ template_folder='../templates',
38
+ static_folder='../static',
39
+ static_url_path='/static',
40
+ )
41
+
42
+ #: Biggest JSON body accepted. A batch of layout edits is kilobytes; a document
43
+ #: replace for a 200-panel figure is a few megabytes. Past this the request is a
44
+ #: mistake, and the client is told so rather than the server parsing it.
45
+ MAX_BODY_BYTES = 64 * 1024 * 1024
46
+
47
+
48
+ def _payload():
49
+ if request.content_length and request.content_length > MAX_BODY_BYTES:
50
+ raise ValueError(f"request body is larger than {MAX_BODY_BYTES // (1024 * 1024)} MB")
51
+ body = request.get_json(silent=True)
52
+ if not isinstance(body, dict):
53
+ raise ValueError("expected a JSON object")
54
+ return body
55
+
56
+
57
+ def _not_found():
58
+ return jsonify(success=False, error="unknown_figure"), 404
59
+
60
+
61
+ def _unreadable(exc):
62
+ return jsonify(success=False, error="unreadable_figure", detail=str(exc)), 422
63
+
64
+
65
+ def _conflict(exc):
66
+ return jsonify(success=False, error="stale_revision", revision=exc.current_revision), 409
67
+
68
+
69
+ # -- the library ---------------------------------------------------------
70
+
71
+
72
+ @figure_builder_bp.route('/api/figures', methods=['GET'])
73
+ def list_figures():
74
+ """Every figure on this machine.
75
+
76
+ Deliberately needs no datasource: the Figures tab is reachable with nothing
77
+ open, and this route existing is also how the Open Project page
78
+ feature-detects the plugin (a 404 means core-only, and the tab stays
79
+ hidden).
80
+ """
81
+ return api.json_response({"success": True, "figures": repository.list_figures()})
82
+
83
+
84
+ @figure_builder_bp.route('/api/figures', methods=['POST'])
85
+ def create_figure():
86
+ try:
87
+ body = _payload()
88
+ except ValueError as exc:
89
+ return jsonify(success=False, error=str(exc)), 400
90
+
91
+ figure_id = repository.create(title=body.get('title'))
92
+ return api.json_response({"success": True, "figure_id": figure_id,
93
+ "document": repository.load(figure_id)})
94
+
95
+
96
+ @figure_builder_bp.route('/api/figures/<figure_id>', methods=['GET'])
97
+ def get_figure(figure_id):
98
+ """The whole document, plus each source's status right now.
99
+
100
+ Status is computed on read rather than stored, because it is a statement
101
+ about the world outside this figure and the world changes without telling
102
+ the figure. Stored `status` in the document is only ever what the user last
103
+ acknowledged.
104
+ """
105
+ try:
106
+ schema.validate_figure_id(figure_id)
107
+ document = repository.load(figure_id)
108
+ except ValueError as exc:
109
+ return jsonify(success=False, error=str(exc)), 400
110
+ except UnknownFigure:
111
+ return _not_found()
112
+ except schema.UnreadableFigure as exc:
113
+ return _unreadable(exc)
114
+
115
+ return api.json_response({
116
+ "success": True,
117
+ "document": document,
118
+ "source_status": {source_id: sources.status_of(source)
119
+ for source_id, source in document["sources"].items()},
120
+ })
121
+
122
+
123
+ @figure_builder_bp.route('/api/figures/<figure_id>', methods=['PATCH'])
124
+ def patch_figure(figure_id):
125
+ """Apply a batch of edits, if the client is writing against the revision it
126
+ last read. The batch is all-or-nothing, and is one undo step."""
127
+ try:
128
+ schema.validate_figure_id(figure_id)
129
+ body = _payload()
130
+ except ValueError as exc:
131
+ return jsonify(success=False, error=str(exc)), 400
132
+
133
+ try:
134
+ revision = repository.apply(figure_id, body.get('base_revision'), body.get('operations'))
135
+ except UnknownFigure:
136
+ return _not_found()
137
+ except ConflictError as exc:
138
+ return _conflict(exc)
139
+ except schema.UnreadableFigure as exc:
140
+ return _unreadable(exc)
141
+ except ValueError as exc:
142
+ return jsonify(success=False, error=str(exc)), 400
143
+
144
+ return jsonify(success=True, revision=revision)
145
+
146
+
147
+ @figure_builder_bp.route('/api/figures/<figure_id>', methods=['PUT'])
148
+ def replace_figure(figure_id):
149
+ """Store a whole document. For import and recovery only -- ordinary editing
150
+ goes through PATCH, which is undoable and journalled."""
151
+ try:
152
+ schema.validate_figure_id(figure_id)
153
+ body = _payload()
154
+ except ValueError as exc:
155
+ return jsonify(success=False, error=str(exc)), 400
156
+
157
+ try:
158
+ revision = repository.replace(figure_id, body.get('base_revision'), body.get('document'))
159
+ except UnknownFigure:
160
+ return _not_found()
161
+ except ConflictError as exc:
162
+ return _conflict(exc)
163
+ except schema.UnreadableFigure as exc:
164
+ return _unreadable(exc)
165
+ except ValueError as exc:
166
+ return jsonify(success=False, error=str(exc)), 400
167
+
168
+ return jsonify(success=True, revision=revision)
169
+
170
+
171
+ @figure_builder_bp.route('/api/figures/<figure_id>', methods=['DELETE'])
172
+ def delete_figure(figure_id):
173
+ try:
174
+ schema.validate_figure_id(figure_id)
175
+ repository.delete(figure_id)
176
+ except ValueError as exc:
177
+ return jsonify(success=False, error=str(exc)), 400
178
+ except UnknownFigure:
179
+ return _not_found()
180
+ return jsonify(success=True)
181
+
182
+
183
+ @figure_builder_bp.route('/api/figures/<figure_id>/duplicate', methods=['POST'])
184
+ def duplicate_figure(figure_id):
185
+ """A copy, previews and imported assets included.
186
+
187
+ A file copy rather than a document round trip: re-rendering every preview
188
+ for a duplicate the user made in order to try a different layout would be
189
+ minutes of work to produce pixels that already exist.
190
+ """
191
+ try:
192
+ schema.validate_figure_id(figure_id)
193
+ body = request.get_json(silent=True) or {}
194
+ new_id = repository.duplicate(figure_id, title=body.get('title'))
195
+ except ValueError as exc:
196
+ return jsonify(success=False, error=str(exc)), 400
197
+ except UnknownFigure:
198
+ return _not_found()
199
+ except schema.UnreadableFigure as exc:
200
+ return _unreadable(exc)
201
+ return jsonify(success=True, figure_id=new_id)
202
+
203
+
204
+ # -- rasters -------------------------------------------------------------
205
+
206
+
207
+ def _image_response(data, media_type):
208
+ """A raster the browser may cache only after revalidating.
209
+
210
+ `no-cache` rather than a max-age: a preview is replaced in place whenever
211
+ its panel changes, at the same URL, and a cached copy would show the user
212
+ the view they just edited away from.
213
+ """
214
+ return Response(data, mimetype=media_type,
215
+ headers={"Cache-Control": "no-cache, must-revalidate"})
216
+
217
+
218
+ @figure_builder_bp.route('/api/figures/<figure_id>/previews/<panel_id>', methods=['GET'])
219
+ def get_preview(figure_id, panel_id):
220
+ try:
221
+ schema.validate_figure_id(figure_id)
222
+ found = repository.get_preview(figure_id, panel_id)
223
+ except ValueError as exc:
224
+ return jsonify(success=False, error=str(exc)), 400
225
+ except UnknownFigure:
226
+ return _not_found()
227
+ if found is None:
228
+ return jsonify(success=False, error="no_preview"), 404
229
+ data, fmt, _ = found
230
+ return _image_response(data, f"image/{fmt}")
231
+
232
+
233
+ @figure_builder_bp.route('/api/figures/<figure_id>/previews/<panel_id>', methods=['POST'])
234
+ def put_preview(figure_id, panel_id):
235
+ """Store a panel's preview raster.
236
+
237
+ The body is the image itself rather than JSON with a base64 field: a WebP
238
+ goes over the wire as bytes, and base64 would add a third to every panel
239
+ the user captures. The render revision rides on the query string, and a
240
+ render older than the one already stored is refused -- see
241
+ repository.put_preview.
242
+ """
243
+ try:
244
+ schema.validate_figure_id(figure_id)
245
+ render_revision = int(request.args.get('render_revision', '0'))
246
+ stored = repository.put_preview(
247
+ figure_id, panel_id, render_revision, request.get_data(),
248
+ width=int(request.args.get('width', '0') or 0),
249
+ height=int(request.args.get('height', '0') or 0),
250
+ )
251
+ except (TypeError, ValueError) as exc:
252
+ return jsonify(success=False, error=str(exc)), 400
253
+ except UnknownFigure:
254
+ return _not_found()
255
+ return jsonify(success=True, stored=stored)
256
+
257
+
258
+ @figure_builder_bp.route('/api/figures/<figure_id>/thumbnail', methods=['GET'])
259
+ def get_thumbnail(figure_id):
260
+ try:
261
+ schema.validate_figure_id(figure_id)
262
+ found = repository.get_thumbnail(figure_id)
263
+ except ValueError as exc:
264
+ return jsonify(success=False, error=str(exc)), 400
265
+ except UnknownFigure:
266
+ return _not_found()
267
+ if found is None:
268
+ return jsonify(success=False, error="no_thumbnail"), 404
269
+ data, fmt = found
270
+ return _image_response(data, f"image/{fmt}")
271
+
272
+
273
+ @figure_builder_bp.route('/api/figures/<figure_id>/thumbnail', methods=['PUT'])
274
+ def put_thumbnail(figure_id):
275
+ try:
276
+ schema.validate_figure_id(figure_id)
277
+ repository.put_thumbnail(figure_id, request.get_data())
278
+ except ValueError as exc:
279
+ return jsonify(success=False, error=str(exc)), 400
280
+ except UnknownFigure:
281
+ return _not_found()
282
+ return jsonify(success=True)
283
+
284
+
285
+ # -- figure-only assets --------------------------------------------------
286
+
287
+
288
+ @figure_builder_bp.route('/api/figures/<figure_id>/assets', methods=['POST'])
289
+ def add_asset(figure_id):
290
+ """Import an image into this figure and nowhere else.
291
+
292
+ The filename comes from the query string and the bytes from the body, so
293
+ nothing here parses a multipart form -- the client already has the file as
294
+ a Blob and this is the cheapest thing to send it as.
295
+ """
296
+ try:
297
+ schema.validate_figure_id(figure_id)
298
+ asset = repository.import_asset(
299
+ figure_id, request.args.get('filename', ''), request.get_data())
300
+ except ValueError as exc:
301
+ return jsonify(success=False, error=str(exc)), 400
302
+ except UnknownFigure:
303
+ return _not_found()
304
+ return jsonify(success=True, **asset)
305
+
306
+
307
+ @figure_builder_bp.route('/api/figures/<figure_id>/assets/<asset_id>', methods=['GET'])
308
+ def get_asset(figure_id, asset_id):
309
+ try:
310
+ schema.validate_figure_id(figure_id)
311
+ path = repository.asset_path(figure_id, asset_id)
312
+ except ValueError as exc:
313
+ return jsonify(success=False, error=str(exc)), 400
314
+ if path is None:
315
+ return jsonify(success=False, error="no_such_asset"), 404
316
+ return send_file(str(path), conditional=True)
317
+
318
+
319
+ # -- sources -------------------------------------------------------------
320
+
321
+
322
+ @figure_builder_bp.route('/api/sources/<datasource>', methods=['GET'])
323
+ def describe_source(datasource):
324
+ """What this project looks like right now, for capture and for relinking.
325
+
326
+ Reads the project record only. Nothing here loads a datasource, so asking
327
+ about eight sources in a row cannot evict the one the user is looking at.
328
+ """
329
+ try:
330
+ return api.json_response({"success": True, "source": sources.describe(datasource)})
331
+ except KeyError:
332
+ return jsonify(success=False, error="unknown_datasource"), 404
333
+
334
+
335
+ # -- source pixels, for Quick Edit ----------------------------------------
336
+
337
+
338
+ @figure_builder_bp.route(
339
+ '/api/figures/<figure_id>/sources/<source_id>/pixels', methods=['GET'])
340
+ def source_pixels(figure_id, source_id):
341
+ """One channel of one region of a figure's source, as raw numbers.
342
+
343
+ For Quick Edit's mini viewer, which composites in the browser so that
344
+ changing a colour or dragging a contrast slider costs no request at all.
345
+ See server/pixels.py for why this exists rather than reusing the viewer's
346
+ own tile routes.
347
+
348
+ The body is uint16 little-endian, row-major, with the shape and the
349
+ actually-read box in headers: a region that ran off the edge of the slide
350
+ has to be drawn in the right PLACE, and a caller given only the pixels
351
+ would centre the wrong thing.
352
+ """
353
+ try:
354
+ schema.validate_figure_id(figure_id)
355
+ document = repository.load(figure_id)
356
+ except ValueError as exc:
357
+ return jsonify(success=False, error=str(exc)), 400
358
+ except UnknownFigure:
359
+ return _not_found()
360
+ except schema.UnreadableFigure as exc:
361
+ return _unreadable(exc)
362
+
363
+ source = document["sources"].get(source_id)
364
+ if source is None:
365
+ return jsonify(success=False, error="unknown_source"), 404
366
+ if source["kind"] != "plexora_project" or not source["datasource"]:
367
+ return jsonify(success=False, error="not_a_project_image"), 400
368
+
369
+ try:
370
+ channel = request.args.get("channel") or ""
371
+ box = tuple(float(request.args.get(name, 0)) for name in ("x", "y", "w", "h"))
372
+ out = (int(request.args.get("out_w", 0)), int(request.args.get("out_h", 0)))
373
+ except (TypeError, ValueError):
374
+ return jsonify(success=False, error="bad_region"), 400
375
+ if box[2] <= 0 or box[3] <= 0:
376
+ return jsonify(success=False, error="bad_region"), 400
377
+
378
+ try:
379
+ array, clipped = pixels.read_region(source["datasource"], channel, box, out)
380
+ except render.RenderError as exc:
381
+ return jsonify(success=False, error=str(exc)), 400
382
+ except KeyError:
383
+ return jsonify(success=False, error="unknown_datasource"), 404
384
+
385
+ return Response(
386
+ array.astype('<u2').tobytes(), mimetype="application/octet-stream",
387
+ headers={
388
+ "X-Fb-Shape": f"{array.shape[1]}x{array.shape[0]}",
389
+ "X-Fb-Box": ",".join(f"{value:.3f}" for value in clipped),
390
+ # Same reasoning as a preview: the pixels behind one URL change
391
+ # whenever the panel does.
392
+ "Cache-Control": "no-cache, must-revalidate",
393
+ })
394
+
395
+
396
+ @figure_builder_bp.route(
397
+ '/api/figures/<figure_id>/sources/<source_id>/pixel_info', methods=['GET'])
398
+ def source_pixel_info(figure_id, source_id):
399
+ """A channel's intensity range, for a contrast slider's domain."""
400
+ try:
401
+ schema.validate_figure_id(figure_id)
402
+ document = repository.load(figure_id)
403
+ except ValueError as exc:
404
+ return jsonify(success=False, error=str(exc)), 400
405
+ except UnknownFigure:
406
+ return _not_found()
407
+ except schema.UnreadableFigure as exc:
408
+ return _unreadable(exc)
409
+
410
+ source = document["sources"].get(source_id)
411
+ if source is None:
412
+ return jsonify(success=False, error="unknown_source"), 404
413
+ if source["kind"] != "plexora_project" or not source["datasource"]:
414
+ return jsonify(success=False, error="not_a_project_image"), 400
415
+
416
+ try:
417
+ stats = pixels.channel_stats(source["datasource"], request.args.get("channel") or "")
418
+ except render.RenderError as exc:
419
+ return jsonify(success=False, error=str(exc)), 400
420
+ except KeyError:
421
+ return jsonify(success=False, error="unknown_datasource"), 404
422
+ return api.json_response({"success": True, "stats": stats})
423
+
424
+
425
+ # -- export --------------------------------------------------------------
426
+
427
+
428
+ @figure_builder_bp.route('/api/figures/<figure_id>/export/preflight', methods=['POST'])
429
+ def export_preflight(figure_id):
430
+ """What the user should know before committing to an export.
431
+
432
+ Answered before a single pixel is rendered, so "this panel is 96 DPI at that
433
+ size" and "this channel is gone" arrive while the dialog is open rather than
434
+ as a warning attached to a file that is already written.
435
+ """
436
+ from plexora.plugins.figure_builder.server import export
437
+
438
+ try:
439
+ schema.validate_figure_id(figure_id)
440
+ body = _payload()
441
+ document = repository.load(figure_id)
442
+ except ValueError as exc:
443
+ return jsonify(success=False, error=str(exc)), 400
444
+ except UnknownFigure:
445
+ return _not_found()
446
+ except schema.UnreadableFigure as exc:
447
+ return _unreadable(exc)
448
+
449
+ return api.json_response({"success": True, **export.preflight(document, body)})
450
+
451
+
452
+ @figure_builder_bp.route('/api/figures/<figure_id>/export', methods=['POST'])
453
+ def start_export(figure_id):
454
+ """Begin an export and answer with a job id.
455
+
456
+ The document is read HERE and handed to the job, so the export renders one
457
+ revision throughout. A figure edited while an export runs must not produce a
458
+ file that is half of one layout and half of another.
459
+ """
460
+ from plexora.plugins.figure_builder.server import export, export_jobs
461
+
462
+ try:
463
+ schema.validate_figure_id(figure_id)
464
+ body = _payload()
465
+ document = repository.load(figure_id)
466
+ except ValueError as exc:
467
+ return jsonify(success=False, error=str(exc)), 400
468
+ except UnknownFigure:
469
+ return _not_found()
470
+ except schema.UnreadableFigure as exc:
471
+ return _unreadable(exc)
472
+
473
+ if body.get("format", "pdf") not in export.FORMATS:
474
+ return jsonify(success=False, error="unknown_format",
475
+ formats=list(export.FORMATS)), 400
476
+ if not any(panel["placement"] for panel in document["panels"].values()):
477
+ return jsonify(success=False,
478
+ error="This figure has no panels on a page yet."), 400
479
+
480
+ job_id = export_jobs.start(figure_id, document, body)
481
+ return jsonify(success=True, job_id=job_id)
482
+
483
+
484
+ @figure_builder_bp.route('/api/figures/<figure_id>/export/<job_id>', methods=['GET'])
485
+ def export_status(figure_id, job_id):
486
+ from plexora.plugins.figure_builder.server import export_jobs
487
+
488
+ job = export_jobs.get(job_id)
489
+ if job is None or job["figure_id"] != figure_id:
490
+ return jsonify(success=False, error="unknown_job"), 404
491
+ if job["status"] == "unavailable":
492
+ # A format this build cannot write. 501 rather than 500: the request was
493
+ # fine and the answer is an install line, not a bug report.
494
+ return jsonify(success=False, error="format_unavailable",
495
+ detail=job["error"], job=job), 501
496
+ return api.json_response({"success": True, "job": job})
497
+
498
+
499
+ @figure_builder_bp.route('/api/figures/<figure_id>/export/<job_id>', methods=['DELETE'])
500
+ def cancel_export(figure_id, job_id):
501
+ """Ask a running export to stop.
502
+
503
+ Cancelling changes nothing about the figure -- it is a render, not an edit
504
+ -- and anything half-written is removed, because a partial PDF in the
505
+ downloads folder is worse than no PDF: it looks finished.
506
+ """
507
+ from plexora.plugins.figure_builder.server import export_jobs
508
+
509
+ job = export_jobs.get(job_id)
510
+ if job is None or job["figure_id"] != figure_id:
511
+ return jsonify(success=False, error="unknown_job"), 404
512
+ export_jobs.cancel(job_id)
513
+ return jsonify(success=True)
514
+
515
+
516
+ @figure_builder_bp.route('/api/figures/<figure_id>/export/<job_id>/download', methods=['GET'])
517
+ def download_export(figure_id, job_id):
518
+ from plexora.plugins.figure_builder.server import export_jobs
519
+
520
+ job = export_jobs.get(job_id)
521
+ if job is None or job["figure_id"] != figure_id:
522
+ return jsonify(success=False, error="unknown_job"), 404
523
+ path = export_jobs.download_path(job_id)
524
+ if not path:
525
+ return jsonify(success=False, error="not_ready"), 409
526
+ return send_file(path, as_attachment=True)
527
+
528
+
529
+ # -- pages of its own ----------------------------------------------------
530
+ #
531
+ # Two pages, neither of which is about a datasource. A figure spans several, so
532
+ # requiring one before it can be opened would make "open my figure" mean "guess
533
+ # which of its four images you meant" -- and the library is reached most often
534
+ # from the state where nothing at all is open.
535
+
536
+
537
+ def _page_data(**values):
538
+ """The context base.html reads, built here rather than through core's
539
+ `template_data`.
540
+
541
+ That looks like duplication and is the boundary working: these are the
542
+ plugin's own pages, base.html is the only thing they inherit, and a plugin
543
+ importing `plexora.server.routes.page_routes` for a helper is a plugin that
544
+ breaks when core reorganises its routes. The keys below are exactly the ones
545
+ base.html reads, and the asset lists come from the descriptor so they cannot
546
+ drift from what the tool path serves.
547
+ """
548
+ from plexora import get_config_names
549
+ from plexora.server import plugins as plugin_registry
550
+
551
+ base_url = app.config.get('PLEXORA_BASE_URL', '')
552
+ data = {
553
+ 'datasource': '',
554
+ 'datasources': get_config_names(),
555
+ 'is_docker': app.config.get('IS_DOCKER', False),
556
+ 'base_url': base_url,
557
+ 'image_kind': '',
558
+ 'active_tool': '',
559
+ 'available_tools': [],
560
+ 'active_tool_scripts': PLUGIN.asset_urls('scripts', base_url),
561
+ 'active_tool_styles': PLUGIN.asset_urls('styles', base_url),
562
+ 'active_tool_panels': {},
563
+ # base.html renders the File menu on every page, this one included --
564
+ # so the entry that leads here has to be present here too, or the menu
565
+ # loses items the moment the user follows one of them.
566
+ 'plugin_nav_items': plugin_registry.nav_items(app, base_url),
567
+ 'figure_builder_url': f"{base_url}{PLUGIN.url_prefix}",
568
+ 'figure_builder_version': VERSION,
569
+ }
570
+ data.update(values)
571
+ return data
572
+
573
+
574
+ @figure_builder_bp.route('/figures', methods=['GET'])
575
+ def library_page():
576
+ """The figure library: every figure on this machine."""
577
+ return render_template('figure_builder/library.html', data=_page_data())
578
+
579
+
580
+ @figure_builder_bp.route('/figure/<figure_id>', methods=['GET'])
581
+ def figure_page(figure_id):
582
+ """One figure, opened with no project."""
583
+ try:
584
+ schema.validate_figure_id(figure_id)
585
+ except ValueError:
586
+ return jsonify(success=False, error="unknown_figure"), 404
587
+ if not repository.exists(figure_id):
588
+ return jsonify(success=False, error="unknown_figure"), 404
589
+
590
+ # `figure_id` rides on the page rather than being fetched, so the workspace
591
+ # knows which figure it is without an extra round trip before it can start
592
+ # the one that matters.
593
+ return render_template('figure_builder/workspace.html',
594
+ data=_page_data(figure_id=figure_id))