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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- plexora-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from plexora import app, get_config, get_config_names, paths
|
|
2
|
+
from plexora.server.models.project import (
|
|
3
|
+
Project, config_transaction, read_config, write_config,
|
|
4
|
+
)
|
|
5
|
+
from plexora.server import plugins as plugin_registry
|
|
6
|
+
from flask import abort, render_template, send_from_directory, request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import datetime
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def template_data(**values):
|
|
13
|
+
base_url = app.config.get('PLEXORA_BASE_URL', '')
|
|
14
|
+
data = {
|
|
15
|
+
'datasource': '',
|
|
16
|
+
'datasources': get_config_names(),
|
|
17
|
+
'is_docker': app.config.get('IS_DOCKER', False),
|
|
18
|
+
# Whether this process is a notebook sidecar or behind a hosted proxy.
|
|
19
|
+
# Templates use it to hide controls that act on the SERVER's machine --
|
|
20
|
+
# Quit, native file dialogs -- which in that mode is not the user's.
|
|
21
|
+
'notebook_mode': app.config.get('PLEXORA_NOTEBOOK_MODE', False),
|
|
22
|
+
'base_url': base_url,
|
|
23
|
+
# Menu entries contributed by installed plugins, as data rather than
|
|
24
|
+
# markup -- core renders them with its own classes. Filled in here, in
|
|
25
|
+
# the one place every page's context is built, because base.html's File
|
|
26
|
+
# menu is on every page and a key it reads must never be absent.
|
|
27
|
+
# Empty on a core-only build, where every consumer renders nothing.
|
|
28
|
+
'plugin_nav_items': plugin_registry.nav_items(app, base_url),
|
|
29
|
+
# Which tool this page view asked to open, and which tools the
|
|
30
|
+
# navbar should offer. Both are per-request: several plugins can be
|
|
31
|
+
# installed at once, and which of them apply depends on the
|
|
32
|
+
# datasource, so neither can be a process-wide flag.
|
|
33
|
+
'active_tool': '',
|
|
34
|
+
'available_tools': [],
|
|
35
|
+
# Assets and panel templates of the active plugin, so templates never
|
|
36
|
+
# name one. Empty on every page that has no tool open.
|
|
37
|
+
'active_tool_scripts': [],
|
|
38
|
+
'active_tool_styles': [],
|
|
39
|
+
'active_tool_panels': {},
|
|
40
|
+
}
|
|
41
|
+
data.update(values)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.route("/")
|
|
46
|
+
def my_index():
|
|
47
|
+
return render_template("index.html", data=template_data())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.route("/favicon.ico")
|
|
51
|
+
def favicon():
|
|
52
|
+
return send_from_directory(
|
|
53
|
+
Path(app.config['CLIENT_PATH']) / 'src' / 'img', 'favicon.ico', conditional=True
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _stamp_last_opened(datasource):
|
|
58
|
+
"""Record when a project was last opened, for the Open Project page's
|
|
59
|
+
"Recently Opened" sort. Best-effort: a failure here should never break
|
|
60
|
+
opening the viewer itself.
|
|
61
|
+
|
|
62
|
+
Skipped outright for a project on a read-only shared root. Catching the
|
|
63
|
+
failure would be correct but slow: write_config retries for two seconds
|
|
64
|
+
past what looks like a transient Windows sharing violation, and paying that
|
|
65
|
+
on every open of a shared project would be a visible stall for a sort key.
|
|
66
|
+
"""
|
|
67
|
+
config_file = Project.config_path_for(datasource)
|
|
68
|
+
if not paths.is_writable(config_file.parent):
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
with config_transaction():
|
|
72
|
+
config_data = read_config(config_file)
|
|
73
|
+
if datasource in config_data:
|
|
74
|
+
config_data[datasource]['lastOpenedAt'] = datetime.datetime.now().isoformat()
|
|
75
|
+
write_config(config_file, config_data)
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.route('/<string:datasource>')
|
|
81
|
+
def image_viewer(datasource):
|
|
82
|
+
datasources = get_config_names()
|
|
83
|
+
if datasource not in datasources:
|
|
84
|
+
# This rule matches any single path segment, so it is the last thing
|
|
85
|
+
# standing between a wrong URL and a 404. It used to render the empty
|
|
86
|
+
# viewer instead, which meant a request for a route that does not exist
|
|
87
|
+
# -- a removed plugin's endpoint, a typo, a probe -- came back 200 with
|
|
88
|
+
# a full HTML page. Callers expecting JSON got HTML and no error.
|
|
89
|
+
abort(404)
|
|
90
|
+
_stamp_last_opened(datasource)
|
|
91
|
+
project = Project.load(datasource)
|
|
92
|
+
image_kind = project.image.kind
|
|
93
|
+
|
|
94
|
+
# Two different lists on purpose. The menu offers every tool COMPATIBLE
|
|
95
|
+
# with this datasource, including ones still missing an input -- opening
|
|
96
|
+
# those is how the user gets asked for it. A tool is only ACTIVATED if it
|
|
97
|
+
# can actually run, so a stale ?tool= link to an uninstalled, inapplicable
|
|
98
|
+
# or not-yet-ready tool renders the plain viewer rather than a panel that
|
|
99
|
+
# cannot work.
|
|
100
|
+
base_url = app.config.get('PLEXORA_BASE_URL', '')
|
|
101
|
+
offered = plugin_registry.tools_for(app, project)
|
|
102
|
+
ready = plugin_registry.ready_tools(app, project)
|
|
103
|
+
requested_tool = request.args.get('tool', '')
|
|
104
|
+
active_tool = requested_tool if any(p.name == requested_tool for p in ready) else ''
|
|
105
|
+
|
|
106
|
+
active = next((p for p in ready if p.name == active_tool), None)
|
|
107
|
+
return render_template(
|
|
108
|
+
'index.html',
|
|
109
|
+
data=template_data(
|
|
110
|
+
datasource=datasource,
|
|
111
|
+
datasources=datasources,
|
|
112
|
+
image_kind=image_kind,
|
|
113
|
+
active_tool=active_tool,
|
|
114
|
+
available_tools=[p.describe() for p in offered],
|
|
115
|
+
active_tool_scripts=active.asset_urls('scripts', base_url) if active else [],
|
|
116
|
+
active_tool_styles=active.asset_urls('styles', base_url) if active else [],
|
|
117
|
+
active_tool_panels=dict(active.panels) if active else {},
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.route("/upload_page")
|
|
124
|
+
def upload_page():
|
|
125
|
+
"""The one import screen: image, optional mask, optional data.
|
|
126
|
+
|
|
127
|
+
It has no "attach to an existing project" mode any more. Adding data to a
|
|
128
|
+
project that already exists is an edit, and the edit page does it -- so
|
|
129
|
+
there is one place that knows how to change a project rather than an
|
|
130
|
+
import form with a second personality.
|
|
131
|
+
"""
|
|
132
|
+
return render_template("upload.html", data=template_data())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.route('/client/<path:filename>')
|
|
138
|
+
def serveClient(filename):
|
|
139
|
+
return send_from_directory(app.config['CLIENT_PATH'], filename, conditional=True)
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# Open Project page -- lists prior projects with search/sort/grid-list view,
|
|
2
|
+
# replacing the old inline project list in the File dropdown (base.html).
|
|
3
|
+
from plexora import app, get_config, paths
|
|
4
|
+
from plexora.server import plugins as plugin_registry
|
|
5
|
+
from plexora.server.models import data_model
|
|
6
|
+
from plexora.server.models.adapters.inspection import source_layers, source_obsm
|
|
7
|
+
from plexora.server.models.project import ROLE_LABELS, ROLE_NAMES, Project
|
|
8
|
+
from plexora.server.routes import tool_routes
|
|
9
|
+
from plexora.server.routes.page_routes import template_data
|
|
10
|
+
|
|
11
|
+
from flask import render_template, jsonify, redirect, request, send_file, abort
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import datetime
|
|
14
|
+
import io
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _base_url():
|
|
19
|
+
return app.config.get('PLEXORA_BASE_URL', '')
|
|
20
|
+
|
|
21
|
+
_THUMBNAIL_CACHE_NAME = '.thumbnail.webp'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.route('/open_project')
|
|
25
|
+
def open_project_page():
|
|
26
|
+
return render_template('open_project.html', data=template_data())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fallback_timestamp(name, home_root=None):
|
|
30
|
+
"""Best-effort date for projects saved before createdAt/lastOpenedAt
|
|
31
|
+
existed: macOS reports true creation time via st_birthtime, everything
|
|
32
|
+
else falls back to st_ctime (metadata-change time, the closest
|
|
33
|
+
approximation available)."""
|
|
34
|
+
try:
|
|
35
|
+
stat = paths.project_dir(name, home_root).stat()
|
|
36
|
+
except OSError:
|
|
37
|
+
return None
|
|
38
|
+
seconds = getattr(stat, 'st_birthtime', stat.st_ctime)
|
|
39
|
+
return datetime.datetime.fromtimestamp(seconds).isoformat()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.route('/projects')
|
|
43
|
+
def list_projects():
|
|
44
|
+
config = get_config()
|
|
45
|
+
# Which root each name came from, read once for the whole listing rather
|
|
46
|
+
# than per project -- `shared` decides whether the card offers rename and
|
|
47
|
+
# delete, so it cannot be left for the client to guess.
|
|
48
|
+
homes = Project.load_roots()
|
|
49
|
+
own = paths.data_root()
|
|
50
|
+
projects = []
|
|
51
|
+
for name, entry in config.items():
|
|
52
|
+
entry = entry or {}
|
|
53
|
+
home_root = homes.get(name)
|
|
54
|
+
created_at = entry.get('createdAt') or _fallback_timestamp(name, home_root)
|
|
55
|
+
last_opened_at = entry.get('lastOpenedAt') or created_at
|
|
56
|
+
projects.append({
|
|
57
|
+
'name': name,
|
|
58
|
+
'createdAt': created_at,
|
|
59
|
+
'lastOpenedAt': last_opened_at,
|
|
60
|
+
'thumbnailUrl': f'/project_thumbnail/{name}',
|
|
61
|
+
'shared': home_root is not None and home_root != own,
|
|
62
|
+
})
|
|
63
|
+
return jsonify(projects)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.route('/project_thumbnail/<string:name>')
|
|
67
|
+
def project_thumbnail(name):
|
|
68
|
+
config = get_config()
|
|
69
|
+
if name not in config:
|
|
70
|
+
abort(404)
|
|
71
|
+
|
|
72
|
+
# A derived image, so it follows the same rule as every other derived
|
|
73
|
+
# artifact: beside the project when that root takes writes, in this user's
|
|
74
|
+
# own root when it does not.
|
|
75
|
+
cache_path = paths.derived_root(name) / _THUMBNAIL_CACHE_NAME
|
|
76
|
+
if cache_path.exists():
|
|
77
|
+
return send_file(cache_path, mimetype='image/webp')
|
|
78
|
+
|
|
79
|
+
result = data_model.generate_thumbnail(name)
|
|
80
|
+
if result is None:
|
|
81
|
+
abort(404)
|
|
82
|
+
encoded, mimetype = result
|
|
83
|
+
try:
|
|
84
|
+
cache_path.write_bytes(encoded)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass # Cache is best-effort -- still serve the freshly generated bytes.
|
|
87
|
+
return send_file(io.BytesIO(encoded), mimetype=mimetype)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# Editing an existing project
|
|
92
|
+
#
|
|
93
|
+
# The old edit page read a project as if its source file were a CSV, rendered
|
|
94
|
+
# the CSV column-matching screen, and posted to a handler that rebuilt the
|
|
95
|
+
# config entry from `{}` -- which discarded the data type and the AnnData read
|
|
96
|
+
# spec, repointed the source at a CSV that did not exist, and reported the
|
|
97
|
+
# whole thing as HTTP 200. Editing an AnnData project destroyed it, silently.
|
|
98
|
+
#
|
|
99
|
+
# What replaces it is generated from the project record, so it shows only the
|
|
100
|
+
# fields that project actually has, and saves through Project.patch(), which
|
|
101
|
+
# merges. There is no code path here that can drop a key it did not name.
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@app.route('/edit_config/<string:name>')
|
|
105
|
+
@app.route('/edit_config', methods=['GET'])
|
|
106
|
+
def edit_project_page(name=None):
|
|
107
|
+
name = name or request.args.get('config')
|
|
108
|
+
project = Project.find(name) if name else None
|
|
109
|
+
if project is None:
|
|
110
|
+
return redirect(f"{_base_url()}/open_project")
|
|
111
|
+
|
|
112
|
+
return render_template('project_edit.html', data=template_data(
|
|
113
|
+
datasetName=name,
|
|
114
|
+
project=_describe(project),
|
|
115
|
+
needs=_needs_for_tool(project, request.args.get('needs')),
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def known_layers(project):
|
|
120
|
+
"""The project with `dataset.layers` filled in, reading the file when they
|
|
121
|
+
were never recorded.
|
|
122
|
+
|
|
123
|
+
In memory only -- nothing is written back, so this is safe on the GET paths
|
|
124
|
+
that render a picker. Shared with tool_routes, which needs the same list to
|
|
125
|
+
put the matrix question in the requirements modal: without the backfill, a
|
|
126
|
+
project imported before layers were recorded would be offered a choice of
|
|
127
|
+
one, which is exactly the project that most needs the choice.
|
|
128
|
+
"""
|
|
129
|
+
if project.dataset and not project.dataset.layers:
|
|
130
|
+
return project.with_layers(source_layers(project.dataset))
|
|
131
|
+
return project
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def known_obsm(project):
|
|
135
|
+
"""The project with `dataset.obsm` filled in, reading the file when it was
|
|
136
|
+
never recorded.
|
|
137
|
+
|
|
138
|
+
Exactly `known_layers`' job for the coordinate question, and needed more
|
|
139
|
+
urgently: a project imported before obsm was recorded had its coordinate
|
|
140
|
+
source chosen by a name heuristic and offers no way to see, let alone
|
|
141
|
+
correct, what was chosen.
|
|
142
|
+
"""
|
|
143
|
+
if project.dataset and not project.dataset.obsm:
|
|
144
|
+
return project.with_obsm(source_obsm(project.dataset))
|
|
145
|
+
return project
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _describe(project):
|
|
149
|
+
"""The project as the edit page and the requirements modal consume it.
|
|
150
|
+
|
|
151
|
+
Deliberately not the raw entry: the page renders sections from `has`, so
|
|
152
|
+
what it asks about is decided here, once, from the record -- rather than by
|
|
153
|
+
a template guessing which fields might apply to which kind of project.
|
|
154
|
+
"""
|
|
155
|
+
project = known_obsm(known_layers(project))
|
|
156
|
+
spec = project.dataset
|
|
157
|
+
layers = list(spec.layers) if spec else []
|
|
158
|
+
return {
|
|
159
|
+
"name": project.name,
|
|
160
|
+
"image": {
|
|
161
|
+
"src": project.image.src,
|
|
162
|
+
"kind": project.image.kind,
|
|
163
|
+
"channelCount": project.image.num_channels,
|
|
164
|
+
"size": [project.image.width, project.image.height],
|
|
165
|
+
},
|
|
166
|
+
"segmentation": {
|
|
167
|
+
"src": project.segmentation.source,
|
|
168
|
+
"available": project.segmentation.available,
|
|
169
|
+
"pending": project.segmentation.pending,
|
|
170
|
+
"mode": project.segmentation.mode,
|
|
171
|
+
},
|
|
172
|
+
"data": None if spec is None else {
|
|
173
|
+
"type": spec.type,
|
|
174
|
+
"src": spec.src,
|
|
175
|
+
"table": spec.table,
|
|
176
|
+
"coordinates": dict(spec.coordinates),
|
|
177
|
+
"features": dict(spec.features),
|
|
178
|
+
"subset": dict(spec.subset),
|
|
179
|
+
"isTransformed": spec.is_transformed,
|
|
180
|
+
"layers": layers,
|
|
181
|
+
},
|
|
182
|
+
# Which matrix the intensities are read from, in the same "X" /
|
|
183
|
+
# "layer:<name>" vocabulary the import form's picker uses, plus whether
|
|
184
|
+
# they are log1p'd on the way in. Two halves of one question -- what
|
|
185
|
+
# numbers is this project actually showing.
|
|
186
|
+
"featureSource": project.feature_source,
|
|
187
|
+
"featureOptions": project.feature_options,
|
|
188
|
+
"featureLog": project.log_transformed,
|
|
189
|
+
# Both, because they are different facts. `cellLayer` is what the
|
|
190
|
+
# project resolves to and is what the select shows; `cellLayerChoice`
|
|
191
|
+
# is whether that was a decision or a default, which is the difference
|
|
192
|
+
# between a form the user can leave alone and one that records an
|
|
193
|
+
# override every time it is saved.
|
|
194
|
+
"cellLayer": project.cell_layer,
|
|
195
|
+
"cellLayerChoice": project.cell_layer_choice,
|
|
196
|
+
"cellLayerOptions": project.cell_layer_options,
|
|
197
|
+
"roles": project.roles.to_dict(),
|
|
198
|
+
"roleLabels": dict(ROLE_LABELS),
|
|
199
|
+
# The role selects, in the vocabulary the question is asked in -- obs
|
|
200
|
+
# columns for AnnData and SpatialData, table columns for a CSV. See
|
|
201
|
+
# Project.role_columns; this page and the requirements modal render
|
|
202
|
+
# from the same three keys so they cannot drift.
|
|
203
|
+
"roleColumns": project.role_columns,
|
|
204
|
+
"roleAnswers": project.role_answers,
|
|
205
|
+
"roleDefaults": project.role_defaults,
|
|
206
|
+
# The coordinate question's own material: every obsm array with its
|
|
207
|
+
# shape, every obs column, and the source recorded now. Separate from
|
|
208
|
+
# the role keys because its answer is a read spec rather than a column
|
|
209
|
+
# name -- an obsm array supplies both axes at once.
|
|
210
|
+
"coordinateOptions": project.coordinate_options,
|
|
211
|
+
"singleImage": bool(spec and spec.single_image),
|
|
212
|
+
"rowNumberIds": bool(spec and spec.row_number_ids),
|
|
213
|
+
"editableRoles": _editable_roles(project),
|
|
214
|
+
"columns": {
|
|
215
|
+
"markers": list(project.columns.markers),
|
|
216
|
+
"metadata": list(project.columns.metadata),
|
|
217
|
+
},
|
|
218
|
+
# Which sections the page renders. A project with only an image has no
|
|
219
|
+
# roles to assign and no columns to classify, so it is not shown empty
|
|
220
|
+
# controls for them.
|
|
221
|
+
"has": {
|
|
222
|
+
"data": project.has_table,
|
|
223
|
+
"columns": project.has_table and spec.type == "csv",
|
|
224
|
+
"readSpec": project.has_table and spec.type in ("anndata", "spatialdata"),
|
|
225
|
+
"segmentation": project.segmentation.requested,
|
|
226
|
+
# Any project with a table. The matrix select needs more than one
|
|
227
|
+
# matrix to be worth showing -- the template guards it on
|
|
228
|
+
# `data.layers`, so a CSV gets the log switch alone -- but that
|
|
229
|
+
# switch is a real question for every format: whether the numbers
|
|
230
|
+
# are raw counts is not something the file says, and a CSV of raw
|
|
231
|
+
# counts is the commonest case of all. This used to be restricted
|
|
232
|
+
# to the formats that state their own marker split, which left CSV
|
|
233
|
+
# projects with no way to reach the transform at all.
|
|
234
|
+
"features": project.has_table,
|
|
235
|
+
# Offered only once there is something to choose between: a project
|
|
236
|
+
# with neither a mask nor coordinates cannot draw cells at all.
|
|
237
|
+
"cellLayer": bool(project.cell_layer_options),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _editable_roles(project):
|
|
243
|
+
"""Which roles this project's user may reassign.
|
|
244
|
+
|
|
245
|
+
All of them, for every format. `cell_id`, `x` and `y` used to be withheld
|
|
246
|
+
from AnnData and SpatialData projects because the adapter synthesizes those
|
|
247
|
+
columns, so repointing the role named a column the loaded table did not
|
|
248
|
+
have. They are answerable now because the answer goes into the read spec
|
|
249
|
+
instead -- which is the only way to say "the mask's label values are in this
|
|
250
|
+
obs column", and without it a segmentation overlay could never line up.
|
|
251
|
+
"""
|
|
252
|
+
return list(ROLE_NAMES) if project.has_table else []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _needs_for_tool(project, tool_name):
|
|
256
|
+
"""The requirements a named tool is still missing, so the edit page can
|
|
257
|
+
highlight exactly those fields. This is the no-JavaScript twin of the
|
|
258
|
+
requirements modal -- same question, same answer, different surface."""
|
|
259
|
+
if not tool_name:
|
|
260
|
+
return None
|
|
261
|
+
plugin = plugin_registry.find(app, tool_name)
|
|
262
|
+
if plugin is None or not plugin.requires.applies_to(project):
|
|
263
|
+
return None
|
|
264
|
+
return {
|
|
265
|
+
"tool": plugin.name,
|
|
266
|
+
"label": plugin.label,
|
|
267
|
+
"missing": [r.describe() for r in plugin.requires.missing_from(project)],
|
|
268
|
+
"confirm": [r.describe() for r in plugin.requires.unconfirmed_from(project)],
|
|
269
|
+
"optional": [r.describe() for r in plugin.requires.optional_missing_from(project)],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@app.route('/project/<string:name>', methods=['POST'])
|
|
274
|
+
def save_project(name):
|
|
275
|
+
"""Apply an edit. Everything except the image can change.
|
|
276
|
+
|
|
277
|
+
Merge semantics throughout: a key the request does not mention is carried
|
|
278
|
+
across untouched. The expensive work -- re-reading a new data file,
|
|
279
|
+
converting a new mask -- runs only when that input actually changed, which
|
|
280
|
+
is what makes saving an unchanged form free.
|
|
281
|
+
"""
|
|
282
|
+
payload = request.get_json(silent=True) or {}
|
|
283
|
+
project = Project.find(name)
|
|
284
|
+
if project is None:
|
|
285
|
+
return jsonify(success=False, error="Unknown project"), 404
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
changed = _apply_edit(project, payload)
|
|
289
|
+
except ValueError as exc:
|
|
290
|
+
# A bad path or an unreadable file is the user's to fix, and they need
|
|
291
|
+
# to be told which. The old handler returned success=False with HTTP
|
|
292
|
+
# 200 and no message, and the client navigated away regardless.
|
|
293
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
294
|
+
|
|
295
|
+
if changed:
|
|
296
|
+
# An answer is only accepted once the file can be read with it, so a
|
|
297
|
+
# role that rewrites the read spec into something the adapter refuses
|
|
298
|
+
# is put back rather than left stored -- see tool_routes.
|
|
299
|
+
error = tool_routes._reload_or_restore(name, project)
|
|
300
|
+
if error:
|
|
301
|
+
return jsonify(success=False, error=error), 400
|
|
302
|
+
|
|
303
|
+
project = Project.find(name)
|
|
304
|
+
return jsonify(success=True, reloaded=changed,
|
|
305
|
+
segmentation_pending=project.segmentation.pending)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _apply_edit(project, payload):
|
|
309
|
+
"""Returns whether anything changed that needs a datasource reload."""
|
|
310
|
+
from plexora.server.routes import import_routes
|
|
311
|
+
|
|
312
|
+
changed = False
|
|
313
|
+
|
|
314
|
+
if "segmentation" in payload:
|
|
315
|
+
wanted = (payload.get("segmentation") or "").strip() or None
|
|
316
|
+
current = project.segmentation.source
|
|
317
|
+
if (wanted or None) != (current or None):
|
|
318
|
+
if wanted and not Path(wanted).expanduser().exists():
|
|
319
|
+
raise ValueError(f"No such segmentation mask: {wanted}")
|
|
320
|
+
import_routes.attach_segmentation(project.name, wanted)
|
|
321
|
+
changed = True
|
|
322
|
+
|
|
323
|
+
swapped = False
|
|
324
|
+
if "data" in payload:
|
|
325
|
+
wanted = (payload.get("data") or "").strip() or None
|
|
326
|
+
current = project.dataset.src if project.dataset else None
|
|
327
|
+
if (wanted or None) != (current or None):
|
|
328
|
+
import_routes.replace_project_data(project.name, wanted, payload)
|
|
329
|
+
changed = True
|
|
330
|
+
swapped = True
|
|
331
|
+
|
|
332
|
+
# Only when the file itself did not change: replace_project_data already
|
|
333
|
+
# reads `features_layer` out of this same payload, and applying it twice
|
|
334
|
+
# would validate the new choice against the old file's layer list.
|
|
335
|
+
if not swapped:
|
|
336
|
+
changed = apply_feature_choice(project, payload) or changed
|
|
337
|
+
|
|
338
|
+
roles = payload.get("roles")
|
|
339
|
+
columns = payload.get("columns")
|
|
340
|
+
coordinates = payload.get("coordinates")
|
|
341
|
+
single_image = payload.get("single_image")
|
|
342
|
+
row_number_ids = payload.get("row_number_ids")
|
|
343
|
+
# Presence, not truth: "" is the client saying "no override, draw whatever
|
|
344
|
+
# this project resolves to", which is a change worth applying and would be
|
|
345
|
+
# dropped by a plain truthiness test.
|
|
346
|
+
chose_layer = "cellLayer" in payload
|
|
347
|
+
cell_layer = payload.get("cellLayer")
|
|
348
|
+
confirm = payload.get("confirm")
|
|
349
|
+
if (roles or columns or coordinates or single_image or row_number_ids
|
|
350
|
+
or chose_layer or confirm):
|
|
351
|
+
def _apply(current):
|
|
352
|
+
if columns:
|
|
353
|
+
current = current.with_columns(columns.get("markers") or [],
|
|
354
|
+
columns.get("metadata") or [])
|
|
355
|
+
# The same helper the requirements modal applies its answers with,
|
|
356
|
+
# so the two surfaces cannot disagree about what a payload means.
|
|
357
|
+
current = tool_routes.apply_column_answers(current, payload)
|
|
358
|
+
if chose_layer:
|
|
359
|
+
current = current.with_cell_layer(cell_layer)
|
|
360
|
+
# Saving this page is the user looking at these values and
|
|
361
|
+
# accepting them, which is exactly what the requirements modal asks
|
|
362
|
+
# for -- so a tool opened afterwards must not ask again.
|
|
363
|
+
return current.with_confirmed(confirm or ())
|
|
364
|
+
|
|
365
|
+
Project.mutate(project.name, _apply)
|
|
366
|
+
# A cell-layer choice on its own changes nothing the datasource holds;
|
|
367
|
+
# it is read from the config by the viewer. Reloading for it would
|
|
368
|
+
# re-open the image to record a preference.
|
|
369
|
+
changed = changed or bool(roles or columns or coordinates
|
|
370
|
+
or row_number_ids)
|
|
371
|
+
|
|
372
|
+
return changed
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def apply_feature_choice(project, payload):
|
|
376
|
+
"""Point the reader at a matrix and/or switch the log1p transform.
|
|
377
|
+
|
|
378
|
+
Shared with the requirements modal (tool_routes), because both surfaces ask
|
|
379
|
+
the same question and getting the answer to stick means the same two writes.
|
|
380
|
+
Returns whether anything changed -- both halves change what every number in
|
|
381
|
+
the app is, so either one needs the datasource re-read.
|
|
382
|
+
"""
|
|
383
|
+
layer = payload.get("features_layer")
|
|
384
|
+
has_log = "features_log" in payload
|
|
385
|
+
log = bool(payload.get("features_log"))
|
|
386
|
+
wants_layer = bool(layer) and layer != project.feature_source
|
|
387
|
+
wants_log = has_log and log != project.log_transformed
|
|
388
|
+
if not wants_layer and not wants_log:
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
# Read outside the write lock: the layer list may come off disk, and holding
|
|
392
|
+
# config.json's lock across a file read serializes every other writer behind
|
|
393
|
+
# it for no reason.
|
|
394
|
+
layers = source_layers(project.dataset) if wants_layer else None
|
|
395
|
+
|
|
396
|
+
def _change(current):
|
|
397
|
+
# The available list goes in first, so a project imported before it was
|
|
398
|
+
# recorded validates the choice against the file rather than against an
|
|
399
|
+
# empty tuple that would reject every layer.
|
|
400
|
+
if wants_layer:
|
|
401
|
+
current = current.with_layers(layers).with_feature_source(layer)
|
|
402
|
+
if wants_log:
|
|
403
|
+
current = current.with_log_transform(log)
|
|
404
|
+
return current
|
|
405
|
+
|
|
406
|
+
Project.mutate(project.name, _change)
|
|
407
|
+
return True
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@app.route('/project/<string:name>/delete', methods=['POST'])
|
|
411
|
+
def delete_project(name):
|
|
412
|
+
"""Remove a project and everything derived for it.
|
|
413
|
+
|
|
414
|
+
POST, not GET: this is an irreversible rmtree, and it used to sit behind a
|
|
415
|
+
link that any crawler, prefetcher or stale bookmark could follow.
|
|
416
|
+
"""
|
|
417
|
+
project = Project.find(name)
|
|
418
|
+
if project is None:
|
|
419
|
+
return jsonify(success=False, error="Unknown project"), 404
|
|
420
|
+
if project.is_shared:
|
|
421
|
+
# A shared project belongs to whoever provisioned the root it sits on.
|
|
422
|
+
# Refused here rather than allowed to fail against a read-only
|
|
423
|
+
# filesystem, so the answer is a sentence rather than a traceback --
|
|
424
|
+
# and so a WRITABLE shared root cannot let one user delete a project
|
|
425
|
+
# every other user on the machine is working from.
|
|
426
|
+
return jsonify(
|
|
427
|
+
success=False,
|
|
428
|
+
error="This project is on a shared data directory and cannot be "
|
|
429
|
+
"deleted from Plexora.",
|
|
430
|
+
), 403
|
|
431
|
+
|
|
432
|
+
project.delete()
|
|
433
|
+
# Only this user's own copy of the project directory. For a project the
|
|
434
|
+
# user owns that is the whole of it; there is nothing else anywhere.
|
|
435
|
+
directory = paths.project_state_dir(name)
|
|
436
|
+
if directory.is_dir():
|
|
437
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
438
|
+
return jsonify(success=True)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# "Quick view" landing page: register a datasource from just a local image
|
|
2
|
+
# path (no CSV/segmentation/h5ad) and hand the client a redirect straight
|
|
3
|
+
# into the viewer. Paths only, never bytes -- see datasource.py's
|
|
4
|
+
# register_image_datasource/register_rgb_datasource docstrings.
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from flask import jsonify, request, send_file
|
|
9
|
+
|
|
10
|
+
from plexora import app, get_config, get_config_names
|
|
11
|
+
from plexora.datasource import (
|
|
12
|
+
_dedupe_dataset_name,
|
|
13
|
+
_derive_dataset_name_from_path,
|
|
14
|
+
_find_existing_datasource_for_image,
|
|
15
|
+
_sniff_quick_view_kind,
|
|
16
|
+
register_image_datasource,
|
|
17
|
+
register_rgb_datasource,
|
|
18
|
+
)
|
|
19
|
+
from plexora.server.routes.import_routes import trim_filepath_quotes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.route('/quick_view', methods=['POST'])
|
|
23
|
+
def quick_view():
|
|
24
|
+
payload = request.get_json(silent=True) or {}
|
|
25
|
+
path = trim_filepath_quotes((payload.get('path') or '').strip())
|
|
26
|
+
|
|
27
|
+
if not path or not Path(path).is_file():
|
|
28
|
+
return jsonify(success=False, error="File does not exist."), 400
|
|
29
|
+
|
|
30
|
+
base_url = app.config.get('PLEXORA_BASE_URL', '')
|
|
31
|
+
|
|
32
|
+
# Same image already registered (quick-viewed before, or imported through
|
|
33
|
+
# the full wizard) -- reopen that project instead of creating a duplicate.
|
|
34
|
+
existing_name = _find_existing_datasource_for_image(path, get_config())
|
|
35
|
+
if existing_name:
|
|
36
|
+
return jsonify(success=True, name=existing_name, redirect=f"{base_url}/{existing_name}")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
kind = _sniff_quick_view_kind(path)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
42
|
+
|
|
43
|
+
name = _dedupe_dataset_name(_derive_dataset_name_from_path(path), get_config_names())
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
if kind == 'ome_tiff':
|
|
47
|
+
register_image_datasource(name=name, image=path)
|
|
48
|
+
else:
|
|
49
|
+
register_rgb_datasource(name=name, image=path)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
52
|
+
|
|
53
|
+
return jsonify(success=True, name=name, redirect=f"{base_url}/{name}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.route('/generated/rgb/<string:datasource>')
|
|
57
|
+
def generate_rgb_image(datasource):
|
|
58
|
+
config = get_config()
|
|
59
|
+
entry = config.get(datasource)
|
|
60
|
+
if not entry or entry.get('image_kind') != 'rgb':
|
|
61
|
+
return jsonify(error="Not an RGB quick-view datasource."), 404
|
|
62
|
+
return send_file(entry['channelFile'])
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Process lifecycle routes for the local desktop app (File > Quit).
|
|
2
|
+
#
|
|
3
|
+
# Plexora has no native window/process the frontend already controls -- it's
|
|
4
|
+
# a headless waitress server the user opens in their own browser -- so Quit
|
|
5
|
+
# has to be a request that tells this process to terminate itself. os._exit
|
|
6
|
+
# skips normal interpreter teardown, which is fine here: waitress's serve()
|
|
7
|
+
# loop never returns on its own, so there's no clean in-process shutdown path
|
|
8
|
+
# to call instead, and the user closing the terminal/window today has the
|
|
9
|
+
# same abrupt effect.
|
|
10
|
+
from plexora import app
|
|
11
|
+
from flask import Response, jsonify
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.route('/shutdown', methods=['POST'])
|
|
16
|
+
def shutdown():
|
|
17
|
+
# In a notebook the server is a sidecar the kernel owns: it was started by
|
|
18
|
+
# PlexoraViewer, it is tracked in that module's registry, and atexit is
|
|
19
|
+
# what stops it. os._exit here would kill it behind the kernel's back,
|
|
20
|
+
# leaving a viewer object whose iframe silently stops loading and no way to
|
|
21
|
+
# get it back short of restarting the kernel. Under a hosted proxy it is
|
|
22
|
+
# worse still -- the "process" the button would end is one the hub spawned.
|
|
23
|
+
if app.config.get('PLEXORA_NOTEBOOK_MODE'):
|
|
24
|
+
return jsonify(error="Shutdown is managed by the notebook session."), 403
|
|
25
|
+
os._exit(0)
|
|
26
|
+
return Response(status=204)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.route('/health', methods=['GET'])
|
|
30
|
+
def health():
|
|
31
|
+
"""Liveness probe for the navbar status indicator (appStatus.js).
|
|
32
|
+
|
|
33
|
+
Deliberately does no work -- not even touching the loaded datasource -- so
|
|
34
|
+
the 10 s poll can never contend with tile serving. It answers exactly one
|
|
35
|
+
question: is this process still accepting requests? An idle page issues no
|
|
36
|
+
other requests, so without this a server that died minutes ago would still
|
|
37
|
+
show as connected.
|
|
38
|
+
"""
|
|
39
|
+
return Response(status=204)
|