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,701 @@
1
+ """Where figures live, and who is allowed to overwrite whom.
2
+
3
+ **Not `plexora.api.store`.** That store is scoped to one datasource by
4
+ construction -- `store(datasource, plugin)` writes a blob into that project's
5
+ own database -- and a figure legitimately spans several: a composite from one
6
+ slide, an H&E from another, a schematic from no project at all. There is no
7
+ datasource that owns such a document, and picking one arbitrarily would mean
8
+ deleting a project silently deletes figures that merely mentioned it.
9
+
10
+ So each figure is its own single-file SQLite database:
11
+
12
+ data_path/.figures/<figure_id>/figure.db the document, previews, journal
13
+ data_path/.figures/<figure_id>/assets/ imported PNG/JPEG/TIFF
14
+ data_path/.figures/<figure_id>/exports/ generated PDFs, swept freely
15
+
16
+ Dot-prefixed so a project literally named "figures" cannot collide with it --
17
+ projects are directories under the same `data_path`. The per-figure DIRECTORY,
18
+ not just the file, is the unit: it is already the layout a future `.plexfig`
19
+ zips up, and deleting a figure is one `rmtree` that cannot take a project with
20
+ it.
21
+
22
+ Three things follow from one-file-per-figure and are worth stating:
23
+
24
+ **The library is a directory scan.** There is no central index, because a
25
+ central index is a single file whose corruption loses every figure at once.
26
+ Scanning costs one small read per figure and degrades one figure at a time: a
27
+ db that cannot be opened becomes one "unreadable" card, and the other
28
+ forty-nine open fine.
29
+
30
+ **Summary counts are denormalised into `meta`.** Listing fifty figures must not
31
+ parse fifty JSON documents. They are written inside the same transaction as the
32
+ document they describe, so they cannot drift from it.
33
+
34
+ **Rollback journal, not WAL.** `data_path` is very often inside Dropbox,
35
+ OneDrive or a network share, and WAL's shared-memory file does not survive
36
+ those. The write rate here is a handful per minute; the throughput WAL buys is
37
+ not worth a mode that fails on the filesystem most of these users have.
38
+
39
+ The revision lives INSIDE the document and is checked here, exactly as ROI's
40
+ is, and for the same reason: two tabs on one figure both hold a full copy and
41
+ both autosave, and last-writer-wins means the stale one's next save silently
42
+ reinstates its whole world.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import shutil
49
+ import sqlite3
50
+ import threading
51
+ import uuid
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+
55
+ from plexora.plugins.figure_builder.server import operations, schema
56
+
57
+ #: Directory under data_path holding every figure. Dot-prefixed: see module
58
+ #: docstring.
59
+ FIGURES_DIRNAME = ".figures"
60
+
61
+ DB_FILENAME = "figure.db"
62
+ ASSETS_DIRNAME = "assets"
63
+
64
+ #: Biggest preview or thumbnail accepted, per panel. A WebP of a canvas crop is
65
+ #: tens of kilobytes; anything past this is a client sending the wrong thing.
66
+ MAX_PREVIEW_BYTES = 8 * 1024 * 1024
67
+ MAX_ASSET_BYTES = 512 * 1024 * 1024
68
+
69
+ #: How long to wait for another thread's write before giving up. Waitress is
70
+ #: multi-threaded and two tabs really do land in two threads; a saved figure is
71
+ #: worth waiting ten seconds for.
72
+ BUSY_TIMEOUT = 10.0
73
+
74
+ #: Extensions an imported asset may have. Checked rather than sniffed because
75
+ #: this becomes a filename on disk that is later served back.
76
+ ASSET_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".ome.tif", ".ome.tiff"}
77
+
78
+
79
+ class ConflictError(Exception):
80
+ """Somebody else wrote since the caller last read.
81
+
82
+ Carries the current revision so the client can say what happened rather
83
+ than just failing.
84
+ """
85
+
86
+ def __init__(self, current_revision):
87
+ super().__init__("this figure changed in another session")
88
+ self.current_revision = current_revision
89
+
90
+
91
+ class UnknownFigure(Exception):
92
+ """No figure with that id, or its directory has gone."""
93
+
94
+
95
+ #: One lock per figure, held across the read-modify-write in `apply`. SQLite's
96
+ #: own busy timeout serialises the WRITES, but not the read-check-write
97
+ #: sequence: without this, two threads can both read revision 41, both pass the
98
+ #: check, and both write revision 42 -- one of them vanishing with nothing said.
99
+ _LOCKS: dict[str, threading.Lock] = {}
100
+ _LOCKS_GUARD = threading.Lock()
101
+
102
+
103
+ def _lock_for(figure_id):
104
+ with _LOCKS_GUARD:
105
+ lock = _LOCKS.get(figure_id)
106
+ if lock is None:
107
+ lock = _LOCKS[figure_id] = threading.Lock()
108
+ return lock
109
+
110
+
111
+ def _now():
112
+ """A timestamp, microseconds included.
113
+
114
+ Kept rather than rounded to the second, unlike the stamps inside the
115
+ document: this one is the library's sort key, and two figures created in
116
+ the same second -- which is what "duplicate, then open the copy" is -- would
117
+ otherwise come back in an order that changes between listings.
118
+ """
119
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
120
+
121
+
122
+ def figures_root() -> Path:
123
+ """Where figures live, resolved on every call.
124
+
125
+ Always this user's own root, never a shared one: a figure can draw on
126
+ several datasources or none, so no project owns it and there is nothing for
127
+ a site-managed root to hold.
128
+
129
+ Deliberately not captured at import time -- `plexora.paths` resolves the
130
+ root on demand, and a module-level constant here would pin whatever the
131
+ answer happened to be when this module was first imported.
132
+ """
133
+ from plexora import paths
134
+
135
+ return paths.figures_root()
136
+
137
+
138
+ def figure_dir(figure_id) -> Path:
139
+ return figures_root() / schema.validate_figure_id(figure_id)
140
+
141
+
142
+ def new_figure_id() -> str:
143
+ """A fresh id. Random rather than derived from the title, because a figure
144
+ is renamed freely and a directory that has to be renamed with it is a
145
+ rename that can half-fail."""
146
+ return "fig_" + uuid.uuid4().hex[:12]
147
+
148
+
149
+ # -- the database --------------------------------------------------------
150
+
151
+ _DDL = """
152
+ CREATE TABLE IF NOT EXISTS meta (
153
+ key TEXT PRIMARY KEY,
154
+ value TEXT NOT NULL
155
+ );
156
+ CREATE TABLE IF NOT EXISTS document (
157
+ id INTEGER PRIMARY KEY CHECK (id = 1),
158
+ revision INTEGER NOT NULL,
159
+ json TEXT NOT NULL,
160
+ saved_at TEXT NOT NULL
161
+ );
162
+ CREATE TABLE IF NOT EXISTS previews (
163
+ panel_id TEXT PRIMARY KEY,
164
+ render_revision INTEGER NOT NULL,
165
+ width INTEGER,
166
+ height INTEGER,
167
+ format TEXT NOT NULL DEFAULT 'webp',
168
+ data BLOB NOT NULL,
169
+ updated_at TEXT
170
+ );
171
+ CREATE TABLE IF NOT EXISTS thumbnail (
172
+ id INTEGER PRIMARY KEY CHECK (id = 1),
173
+ format TEXT NOT NULL DEFAULT 'webp',
174
+ data BLOB NOT NULL,
175
+ updated_at TEXT
176
+ );
177
+ CREATE TABLE IF NOT EXISTS assets (
178
+ asset_id TEXT PRIMARY KEY,
179
+ filename TEXT NOT NULL,
180
+ media_type TEXT,
181
+ bytes INTEGER,
182
+ added_at TEXT
183
+ );
184
+ CREATE TABLE IF NOT EXISTS journal (
185
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ base_revision INTEGER,
187
+ ops_json TEXT,
188
+ applied_at TEXT
189
+ );
190
+ """
191
+
192
+ #: How many journal rows one figure keeps. The journal exists so an abnormal
193
+ #: exit leaves something to recover from; it is not a permanent history, and an
194
+ #: unbounded one turns a 300 KB figure into a 40 MB one over a long session.
195
+ JOURNAL_LIMIT = 500
196
+
197
+
198
+ def _connect(path: Path):
199
+ connection = sqlite3.connect(str(path), timeout=BUSY_TIMEOUT, isolation_level="DEFERRED")
200
+ connection.row_factory = sqlite3.Row
201
+ connection.execute("PRAGMA foreign_keys = ON")
202
+ return connection
203
+
204
+
205
+ def _open(figure_id):
206
+ """A connection to an existing figure, or UnknownFigure.
207
+
208
+ Checked rather than allowed to create: `sqlite3.connect` happily conjures an
209
+ empty database for a path that does not exist, which would turn "this figure
210
+ was deleted" into "this figure is empty" -- and the next autosave would make
211
+ that true.
212
+ """
213
+ path = figure_dir(figure_id) / DB_FILENAME
214
+ if not path.is_file():
215
+ raise UnknownFigure(figure_id)
216
+ return _connect(path)
217
+
218
+
219
+ # -- creating and listing ------------------------------------------------
220
+
221
+
222
+ def create(title=None) -> str:
223
+ """A new, empty figure. Returns its id."""
224
+ figure_id = new_figure_id()
225
+ directory = figure_dir(figure_id)
226
+ directory.mkdir(parents=True, exist_ok=True)
227
+ stamp = _now()
228
+ document = schema.new_document(figure_id, title=title, created_at=stamp)
229
+
230
+ connection = _connect(directory / DB_FILENAME)
231
+ try:
232
+ with connection:
233
+ connection.executescript(_DDL)
234
+ _write_document(connection, document, stamp)
235
+ finally:
236
+ connection.close()
237
+ return figure_id
238
+
239
+
240
+ def list_figures() -> list[dict]:
241
+ """Every figure on this machine, newest first.
242
+
243
+ One small read per figure and no shared index -- see the module docstring.
244
+ A figure whose database cannot be opened comes back with
245
+ `readable: False` rather than being omitted: a figure that has gone wrong is
246
+ exactly the one the user needs to see, and a listing that silently skips it
247
+ presents "damaged" as "deleted".
248
+ """
249
+ root = figures_root()
250
+ if not root.is_dir():
251
+ return []
252
+
253
+ figures = []
254
+ for directory in sorted(root.iterdir()):
255
+ if not directory.is_dir() or not (directory / DB_FILENAME).is_file():
256
+ continue
257
+ try:
258
+ schema.validate_figure_id(directory.name)
259
+ except ValueError:
260
+ continue
261
+ figures.append(_summary(directory))
262
+
263
+ figures.sort(key=lambda entry: entry.get("updated_at") or "", reverse=True)
264
+ return figures
265
+
266
+
267
+ def _summary(directory: Path) -> dict:
268
+ figure_id = directory.name
269
+ blank = {"figure_id": figure_id, "title": figure_id, "readable": False,
270
+ "created_at": "", "updated_at": "", "revision": 0,
271
+ "page_count": 0, "panel_count": 0, "sources": [],
272
+ "has_thumbnail": False}
273
+ try:
274
+ connection = _connect(directory / DB_FILENAME)
275
+ except sqlite3.Error:
276
+ return blank
277
+ try:
278
+ meta = {row["key"]: row["value"] for row in connection.execute("SELECT key, value FROM meta")}
279
+ has_thumbnail = bool(connection.execute(
280
+ "SELECT 1 FROM thumbnail WHERE id = 1").fetchone())
281
+ except sqlite3.Error:
282
+ return blank
283
+ finally:
284
+ connection.close()
285
+
286
+ if not meta:
287
+ return blank
288
+ try:
289
+ sources = json.loads(meta.get("sources") or "[]")
290
+ except ValueError:
291
+ sources = []
292
+ return {
293
+ "figure_id": figure_id,
294
+ "title": meta.get("title") or figure_id,
295
+ "readable": True,
296
+ "created_at": meta.get("created_at", ""),
297
+ "updated_at": meta.get("updated_at", ""),
298
+ "revision": int(meta.get("revision") or 0),
299
+ "page_count": int(meta.get("page_count") or 0),
300
+ "panel_count": int(meta.get("panel_count") or 0),
301
+ "sources": sources if isinstance(sources, list) else [],
302
+ "has_thumbnail": has_thumbnail,
303
+ }
304
+
305
+
306
+ def duplicate(figure_id, title=None) -> str:
307
+ """A copy of a figure under a new id, previews and assets included.
308
+
309
+ The directory is copied and then the identity inside it is rewritten,
310
+ rather than the document being replayed into a fresh figure. Replaying
311
+ would drop every cached preview, and re-rendering them is minutes of work
312
+ to reproduce pixels that are already on disk -- for an action whose whole
313
+ purpose is usually "let me try a different layout without losing this one".
314
+
315
+ The copy's title gets " copy" appended unless one is given, because two rows
316
+ in the library both reading "Figure 1" are two rows the user cannot tell
317
+ apart.
318
+ """
319
+ source_dir = figure_dir(figure_id)
320
+ if not (source_dir / DB_FILENAME).is_file():
321
+ raise UnknownFigure(figure_id)
322
+
323
+ document = load(figure_id)
324
+ new_id = new_figure_id()
325
+ target_dir = figures_root() / new_id
326
+ with _lock_for(figure_id):
327
+ shutil.copytree(source_dir, target_dir)
328
+
329
+ document["figure_id"] = new_id
330
+ document["title"] = schema.clean_text(title) or f"{document['title']} copy"
331
+ stamp = _now()
332
+ document["created_at"] = stamp
333
+ # Revisions restart: the copy is a new document, and inheriting the
334
+ # original's number would let a tab still holding the original's revision
335
+ # write into the copy without ever looking stale.
336
+ document["revision"] = 0
337
+
338
+ connection = _connect(target_dir / DB_FILENAME)
339
+ try:
340
+ with connection:
341
+ # The journal describes edits to the ORIGINAL. Carrying it over
342
+ # would offer a recovery that reinstates another figure's history
343
+ # into this one.
344
+ connection.execute("DELETE FROM journal")
345
+ _write_document(connection, document, stamp)
346
+ finally:
347
+ connection.close()
348
+ return new_id
349
+
350
+
351
+ def delete(figure_id) -> None:
352
+ """Remove a figure and everything it owns.
353
+
354
+ An rmtree of a directory whose name has already been through
355
+ `validate_figure_id`, which is the only reason that pattern is as narrow as
356
+ it is.
357
+ """
358
+ directory = figure_dir(figure_id)
359
+ if not directory.is_dir():
360
+ raise UnknownFigure(figure_id)
361
+ with _lock_for(figure_id):
362
+ shutil.rmtree(directory)
363
+
364
+
365
+ def exists(figure_id) -> bool:
366
+ try:
367
+ return (figure_dir(figure_id) / DB_FILENAME).is_file()
368
+ except ValueError:
369
+ return False
370
+
371
+
372
+ # -- reading and writing the document ------------------------------------
373
+
374
+
375
+ def load(figure_id) -> dict:
376
+ """The stored document, normalized.
377
+
378
+ Raises UnknownFigure if it is not there and UnreadableFigure if it is there
379
+ and cannot be understood. The second case is deliberately loud: handing back
380
+ an empty document presents "your figure cannot be read" as "your figure is
381
+ empty", and the next autosave makes it true.
382
+ """
383
+ connection = _open(figure_id)
384
+ try:
385
+ row = connection.execute(
386
+ "SELECT revision, json FROM document WHERE id = 1").fetchone()
387
+ except sqlite3.Error as exc:
388
+ raise schema.UnreadableFigure(f"this figure's database is damaged: {exc}") from exc
389
+ finally:
390
+ connection.close()
391
+
392
+ if row is None:
393
+ raise schema.UnreadableFigure("this figure holds no document")
394
+ try:
395
+ raw = json.loads(row["json"])
396
+ except ValueError as exc:
397
+ raise schema.UnreadableFigure(f"this figure's document could not be read: {exc}") from exc
398
+
399
+ document = schema.normalize_document(raw, figure_id=figure_id)
400
+ # The column is the authority on the revision, not the copy inside the
401
+ # JSON: the column is what the conflict check compares and what an index
402
+ # could be built on later.
403
+ document["revision"] = int(row["revision"])
404
+ return document
405
+
406
+
407
+ def apply(figure_id, base_revision, ops) -> int:
408
+ """Apply operations if the caller was up to date, and bump the revision.
409
+
410
+ Returns the new revision. Raises ConflictError when somebody else wrote
411
+ first, ValueError when an operation is invalid, UnreadableFigure when the
412
+ stored document cannot be read -- and stores nothing in any of those cases.
413
+ """
414
+ if not isinstance(base_revision, int) or isinstance(base_revision, bool):
415
+ raise ValueError("base_revision must be an integer")
416
+
417
+ with _lock_for(figure_id):
418
+ document = load(figure_id)
419
+ if document["revision"] != base_revision:
420
+ raise ConflictError(document["revision"])
421
+
422
+ updated = operations.apply_operations(document, ops)
423
+ updated["revision"] = document["revision"] + 1
424
+ stamp = _now()
425
+ updated["updated_at"] = stamp
426
+
427
+ connection = _open(figure_id)
428
+ try:
429
+ with connection:
430
+ _write_document(connection, updated, stamp)
431
+ _append_journal(connection, base_revision, ops, stamp)
432
+ finally:
433
+ connection.close()
434
+ return updated["revision"]
435
+
436
+
437
+ def replace(figure_id, base_revision, document) -> int:
438
+ """Store a whole document, for the paths that build one rather than edit one.
439
+
440
+ Same revision check, same lock. Used by import and by the recovery path;
441
+ ordinary editing goes through `apply`, which can be undone and journalled.
442
+ """
443
+ if not isinstance(base_revision, int) or isinstance(base_revision, bool):
444
+ raise ValueError("base_revision must be an integer")
445
+
446
+ with _lock_for(figure_id):
447
+ current = load(figure_id)
448
+ if current["revision"] != base_revision:
449
+ raise ConflictError(current["revision"])
450
+
451
+ updated = schema.normalize_document(document, figure_id=figure_id)
452
+ updated["revision"] = current["revision"] + 1
453
+ updated["created_at"] = current["created_at"] or updated["created_at"]
454
+ stamp = _now()
455
+ updated["updated_at"] = stamp
456
+
457
+ connection = _open(figure_id)
458
+ try:
459
+ with connection:
460
+ _write_document(connection, updated, stamp)
461
+ finally:
462
+ connection.close()
463
+ return updated["revision"]
464
+
465
+
466
+ def _write_document(connection, document, stamp):
467
+ """The document plus the denormalised summary, in one transaction.
468
+
469
+ Both together, always: a `meta` row that disagrees with the document it
470
+ describes is a library card reporting a figure that does not exist.
471
+ """
472
+ connection.execute(
473
+ "INSERT INTO document (id, revision, json, saved_at) VALUES (1, ?, ?, ?) "
474
+ "ON CONFLICT(id) DO UPDATE SET revision = excluded.revision, "
475
+ "json = excluded.json, saved_at = excluded.saved_at",
476
+ (document["revision"], json.dumps(document, separators=(",", ":")), stamp),
477
+ )
478
+ sources = sorted({source["display_name"] or source["datasource"]
479
+ for source in document["sources"].values()
480
+ if source["display_name"] or source["datasource"]})
481
+ meta = {
482
+ "schema_version": str(schema.SCHEMA_VERSION),
483
+ "figure_id": document["figure_id"],
484
+ "title": document["title"],
485
+ "created_at": document["created_at"],
486
+ "updated_at": stamp,
487
+ "revision": str(document["revision"]),
488
+ "page_count": str(len(document["pages"])),
489
+ "panel_count": str(len(document["panels"])),
490
+ "sources": json.dumps(sources),
491
+ }
492
+ connection.executemany(
493
+ "INSERT INTO meta (key, value) VALUES (?, ?) "
494
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
495
+ list(meta.items()),
496
+ )
497
+
498
+
499
+ def _append_journal(connection, base_revision, ops, stamp):
500
+ """Record the operations that were applied.
501
+
502
+ Written but not yet read back by anything: the recovery UI is a later
503
+ milestone, and a journal that only starts being kept when the UI ships can
504
+ recover nothing from the sessions before it. Rows are cheap; the sessions
505
+ are not repeatable.
506
+ """
507
+ try:
508
+ payload = json.dumps(ops, separators=(",", ":"))
509
+ except (TypeError, ValueError):
510
+ return
511
+ connection.execute(
512
+ "INSERT INTO journal (base_revision, ops_json, applied_at) VALUES (?, ?, ?)",
513
+ (base_revision, payload, stamp),
514
+ )
515
+ connection.execute(
516
+ "DELETE FROM journal WHERE seq <= "
517
+ "(SELECT MAX(seq) FROM journal) - ?", (JOURNAL_LIMIT,),
518
+ )
519
+
520
+
521
+ # -- previews and thumbnails ---------------------------------------------
522
+
523
+
524
+ def put_preview(figure_id, panel_id, render_revision, data, width=0, height=0, fmt="webp"):
525
+ """Store a panel's preview raster, unless a newer one is already there.
526
+
527
+ The refusal is the point. Previews are rendered asynchronously and a slow
528
+ one can land after a fast one that was queued later -- so without this, a
529
+ user who changes a channel and then changes it back sees the FIRST render
530
+ overwrite the second and the panel shows a state they have left. Comparing
531
+ render revisions makes a late arrival a no-op instead.
532
+
533
+ Returns True if it was stored.
534
+ """
535
+ schema.validate_id(panel_id, "panel id")
536
+ if not isinstance(render_revision, int) or isinstance(render_revision, bool):
537
+ raise ValueError("render_revision must be an integer")
538
+ if not data:
539
+ raise ValueError("a preview needs image data")
540
+ if len(data) > MAX_PREVIEW_BYTES:
541
+ raise ValueError(f"preview is larger than {MAX_PREVIEW_BYTES // (1024 * 1024)} MB")
542
+
543
+ connection = _open(figure_id)
544
+ try:
545
+ with connection:
546
+ row = connection.execute(
547
+ "SELECT render_revision FROM previews WHERE panel_id = ?", (panel_id,)).fetchone()
548
+ if row is not None and int(row["render_revision"]) > render_revision:
549
+ return False
550
+ connection.execute(
551
+ "INSERT INTO previews (panel_id, render_revision, width, height, format, data, updated_at) "
552
+ "VALUES (?, ?, ?, ?, ?, ?, ?) "
553
+ "ON CONFLICT(panel_id) DO UPDATE SET render_revision = excluded.render_revision, "
554
+ "width = excluded.width, height = excluded.height, format = excluded.format, "
555
+ "data = excluded.data, updated_at = excluded.updated_at",
556
+ (panel_id, render_revision, int(width or 0), int(height or 0),
557
+ fmt, sqlite3.Binary(data), _now()),
558
+ )
559
+ return True
560
+ finally:
561
+ connection.close()
562
+
563
+
564
+ def get_preview(figure_id, panel_id):
565
+ """(bytes, format, render_revision) or None."""
566
+ schema.validate_id(panel_id, "panel id")
567
+ connection = _open(figure_id)
568
+ try:
569
+ row = connection.execute(
570
+ "SELECT data, format, render_revision FROM previews WHERE panel_id = ?",
571
+ (panel_id,)).fetchone()
572
+ finally:
573
+ connection.close()
574
+ if row is None:
575
+ return None
576
+ return bytes(row["data"]), row["format"], int(row["render_revision"])
577
+
578
+
579
+ def drop_previews(figure_id, panel_ids):
580
+ """Forget previews for panels that no longer exist.
581
+
582
+ Best-effort housekeeping called after a delete: a stale preview costs disk
583
+ and nothing else, so a failure here must never fail the delete that
584
+ triggered it.
585
+ """
586
+ if not panel_ids:
587
+ return
588
+ try:
589
+ connection = _open(figure_id)
590
+ except UnknownFigure:
591
+ return
592
+ try:
593
+ with connection:
594
+ connection.executemany("DELETE FROM previews WHERE panel_id = ?",
595
+ [(panel_id,) for panel_id in panel_ids])
596
+ except sqlite3.Error:
597
+ pass
598
+ finally:
599
+ connection.close()
600
+
601
+
602
+ def put_thumbnail(figure_id, data, fmt="webp"):
603
+ if not data:
604
+ raise ValueError("a thumbnail needs image data")
605
+ if len(data) > MAX_PREVIEW_BYTES:
606
+ raise ValueError(f"thumbnail is larger than {MAX_PREVIEW_BYTES // (1024 * 1024)} MB")
607
+ connection = _open(figure_id)
608
+ try:
609
+ with connection:
610
+ connection.execute(
611
+ "INSERT INTO thumbnail (id, format, data, updated_at) VALUES (1, ?, ?, ?) "
612
+ "ON CONFLICT(id) DO UPDATE SET format = excluded.format, "
613
+ "data = excluded.data, updated_at = excluded.updated_at",
614
+ (fmt, sqlite3.Binary(data), _now()),
615
+ )
616
+ finally:
617
+ connection.close()
618
+
619
+
620
+ def get_thumbnail(figure_id):
621
+ """(bytes, format) or None."""
622
+ connection = _open(figure_id)
623
+ try:
624
+ row = connection.execute("SELECT data, format FROM thumbnail WHERE id = 1").fetchone()
625
+ finally:
626
+ connection.close()
627
+ return (bytes(row["data"]), row["format"]) if row is not None else None
628
+
629
+
630
+ # -- figure-only assets --------------------------------------------------
631
+
632
+
633
+ def import_asset(figure_id, filename, data):
634
+ """Copy an imported image into this figure's own directory.
635
+
636
+ Figure-only by design: a schematic or a supporting RGB panel is not a
637
+ project, and making the user create one to drop a PNG into a figure is the
638
+ setup step this whole plugin exists to remove. The bytes live beside the
639
+ database rather than inside it -- a BLOB column would be read whole on
640
+ every open, and the directory is already the portable unit.
641
+ """
642
+ if not data:
643
+ raise ValueError("an imported file needs contents")
644
+ if len(data) > MAX_ASSET_BYTES:
645
+ raise ValueError(f"file is larger than {MAX_ASSET_BYTES // (1024 * 1024)} MB")
646
+
647
+ name = schema.clean_text(filename, 120)
648
+ suffix = _asset_suffix(name)
649
+ if suffix is None:
650
+ raise ValueError(f"{name or 'this file'} is not an image Plexora can import")
651
+
652
+ asset_id = "ast_" + uuid.uuid4().hex[:12]
653
+ directory = figure_dir(figure_id) / ASSETS_DIRNAME
654
+ if not (figure_dir(figure_id) / DB_FILENAME).is_file():
655
+ raise UnknownFigure(figure_id)
656
+ directory.mkdir(parents=True, exist_ok=True)
657
+ # Named by id, never by the user's filename: the original name is data, and
658
+ # data does not belong in a path. The name is kept in the table for display.
659
+ (directory / f"{asset_id}{suffix}").write_bytes(data)
660
+
661
+ connection = _open(figure_id)
662
+ try:
663
+ with connection:
664
+ connection.execute(
665
+ "INSERT INTO assets (asset_id, filename, media_type, bytes, added_at) "
666
+ "VALUES (?, ?, ?, ?, ?)",
667
+ (asset_id, name, _media_type(suffix), len(data), _now()),
668
+ )
669
+ finally:
670
+ connection.close()
671
+ return {"asset_id": asset_id, "filename": name,
672
+ "media_type": _media_type(suffix), "bytes": len(data)}
673
+
674
+
675
+ def asset_path(figure_id, asset_id):
676
+ """Where an imported file physically is, or None."""
677
+ if not schema.clean_id(asset_id) or not asset_id.startswith("ast_"):
678
+ return None
679
+ directory = figure_dir(figure_id) / ASSETS_DIRNAME
680
+ if not directory.is_dir():
681
+ return None
682
+ for path in directory.iterdir():
683
+ if path.is_file() and path.name.startswith(asset_id):
684
+ return path
685
+ return None
686
+
687
+
688
+ def _asset_suffix(filename):
689
+ lowered = (filename or "").lower()
690
+ for extension in sorted(ASSET_EXTENSIONS, key=len, reverse=True):
691
+ if lowered.endswith(extension):
692
+ return extension
693
+ return None
694
+
695
+
696
+ def _media_type(suffix):
697
+ if suffix == ".png":
698
+ return "image/png"
699
+ if suffix in (".jpg", ".jpeg"):
700
+ return "image/jpeg"
701
+ return "image/tiff"