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