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