arrayview 0.33.1__tar.gz → 0.35.0__tar.gz
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.
- {arrayview-0.33.1 → arrayview-0.35.0}/PKG-INFO +1 -1
- {arrayview-0.33.1 → arrayview-0.35.0}/pyproject.toml +1 -1
- arrayview-0.35.0/src/arrayview/_config.py +352 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launcher.py +48 -36
- arrayview-0.35.0/src/arrayview/_routes_preferences.py +114 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_server.py +19 -2
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_viewer.html +400 -17
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_extension.py +1 -1
- arrayview-0.35.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_api.py +174 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_config.py +72 -0
- arrayview-0.35.0/tests/test_preferences_ui.py +185 -0
- arrayview-0.33.1/src/arrayview/_config.py +0 -135
- arrayview-0.33.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/.gitignore +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/LICENSE +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/README.md +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_codex_open.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_io.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_session.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/conftest.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_browser.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_cli.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_codex_open.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_command_reachability.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_dicom.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_interactions.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_io.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_plan.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_lifecycle_contract.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_torch.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/ui_audit.py +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.33.1 → arrayview-0.35.0}/tests/visual_smoke.py +0 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Read/write ~/.arrayview/config.toml for persistent user preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _datetime
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
|
|
12
|
+
CONFIG_PATH = os.path.expanduser("~/.arrayview/config.toml")
|
|
13
|
+
|
|
14
|
+
_VALID_WINDOW_MODES = {"browser", "vscode", "native", "inline", "none"}
|
|
15
|
+
_VALID_ENV_KEYS = {"default", "terminal", "vscode", "jupyter", "ssh", "julia"}
|
|
16
|
+
_VALID_THEMES = {"dark", "light"}
|
|
17
|
+
_VALID_ORTHO_LAYOUTS = {"horizontal", "big-left"}
|
|
18
|
+
_VALID_DIMBAR_MODES = {"compact", "extended"}
|
|
19
|
+
_CONFIG_LOCK = threading.Lock()
|
|
20
|
+
|
|
21
|
+
WINDOW_PREFERENCE_OPTIONS = {
|
|
22
|
+
"default": ["native", "browser", "none"],
|
|
23
|
+
"terminal": ["native", "browser", "none"],
|
|
24
|
+
"vscode": ["vscode", "browser", "native", "none"],
|
|
25
|
+
"jupyter": ["inline", "browser", "native", "none"],
|
|
26
|
+
"ssh": ["browser", "none"],
|
|
27
|
+
"julia": ["browser", "native", "none"],
|
|
28
|
+
}
|
|
29
|
+
BUILTIN_PREFERENCES = {
|
|
30
|
+
"window": {
|
|
31
|
+
"default": "native",
|
|
32
|
+
"terminal": "native",
|
|
33
|
+
"vscode": "vscode",
|
|
34
|
+
"jupyter": "inline",
|
|
35
|
+
"ssh": "browser",
|
|
36
|
+
"julia": "browser",
|
|
37
|
+
},
|
|
38
|
+
"viewer": {
|
|
39
|
+
"theme": "dark",
|
|
40
|
+
"rounded_panes": True,
|
|
41
|
+
"ortho_layout": None,
|
|
42
|
+
"dimbar_mode": "compact",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
PREFERENCE_SCHEMA = {
|
|
46
|
+
"window": WINDOW_PREFERENCE_OPTIONS,
|
|
47
|
+
"viewer": {
|
|
48
|
+
"theme": sorted(_VALID_THEMES),
|
|
49
|
+
"rounded_panes": "boolean",
|
|
50
|
+
"colormaps": "non-empty list of names",
|
|
51
|
+
"ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
|
|
52
|
+
"dimbar_mode": sorted(_VALID_DIMBAR_MODES),
|
|
53
|
+
},
|
|
54
|
+
"nninteractive": {"url": "string"},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MalformedConfigError(ValueError):
|
|
59
|
+
"""Raised when an update would overwrite an unreadable config file."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InvalidPreferenceError(ValueError):
|
|
63
|
+
"""Raised when an update contains an unknown or invalid preference."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_config_strict() -> dict:
|
|
67
|
+
if not os.path.isfile(CONFIG_PATH):
|
|
68
|
+
return {}
|
|
69
|
+
try:
|
|
70
|
+
with open(CONFIG_PATH, encoding="utf-8") as f:
|
|
71
|
+
parsed = _parse_toml(f.read())
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise MalformedConfigError("The existing config.toml is malformed") from exc
|
|
74
|
+
if not isinstance(parsed, dict):
|
|
75
|
+
raise MalformedConfigError("The existing config.toml is not a TOML table")
|
|
76
|
+
return parsed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config() -> dict:
|
|
80
|
+
"""Load config from TOML file. Returns {} on missing/malformed file."""
|
|
81
|
+
try:
|
|
82
|
+
return _load_config_strict()
|
|
83
|
+
except MalformedConfigError:
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save_config(config: dict) -> None:
|
|
88
|
+
"""Atomically write a config dict to its TOML file."""
|
|
89
|
+
folder = os.path.dirname(CONFIG_PATH)
|
|
90
|
+
if folder:
|
|
91
|
+
os.makedirs(folder, exist_ok=True)
|
|
92
|
+
payload = _dump_toml(config)
|
|
93
|
+
fd, temp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=folder or ".")
|
|
94
|
+
try:
|
|
95
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
96
|
+
f.write(payload)
|
|
97
|
+
f.flush()
|
|
98
|
+
os.fsync(f.fileno())
|
|
99
|
+
os.replace(temp_path, CONFIG_PATH)
|
|
100
|
+
except Exception:
|
|
101
|
+
try:
|
|
102
|
+
os.unlink(temp_path)
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
pass
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalized_string(value, allowed: set[str], label: str) -> str:
|
|
109
|
+
if not isinstance(value, str) or value.strip().lower() not in allowed:
|
|
110
|
+
raise InvalidPreferenceError(f"{label} must be one of: {', '.join(sorted(allowed))}")
|
|
111
|
+
return value.strip().lower()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _validate_preference(section: str, key: str, value):
|
|
115
|
+
if section == "window" and key in _VALID_ENV_KEYS:
|
|
116
|
+
return _normalized_string(
|
|
117
|
+
value, set(WINDOW_PREFERENCE_OPTIONS[key]), f"window.{key}"
|
|
118
|
+
)
|
|
119
|
+
if section == "viewer" and key == "theme":
|
|
120
|
+
return _normalized_string(value, _VALID_THEMES, "viewer.theme")
|
|
121
|
+
if section == "viewer" and key == "rounded_panes":
|
|
122
|
+
if not isinstance(value, bool):
|
|
123
|
+
raise InvalidPreferenceError("viewer.rounded_panes must be a boolean")
|
|
124
|
+
return value
|
|
125
|
+
if section == "viewer" and key == "colormaps":
|
|
126
|
+
if (
|
|
127
|
+
not isinstance(value, list)
|
|
128
|
+
or not value
|
|
129
|
+
or not all(isinstance(item, str) and item.strip() for item in value)
|
|
130
|
+
):
|
|
131
|
+
raise InvalidPreferenceError("viewer.colormaps must be a non-empty list of names")
|
|
132
|
+
normalized = [item.strip() for item in value]
|
|
133
|
+
if len(set(normalized)) != len(normalized):
|
|
134
|
+
raise InvalidPreferenceError("viewer.colormaps must not contain duplicates")
|
|
135
|
+
return normalized
|
|
136
|
+
if section == "viewer" and key == "ortho_layout":
|
|
137
|
+
return _normalized_string(value, _VALID_ORTHO_LAYOUTS, "viewer.ortho_layout")
|
|
138
|
+
if section == "viewer" and key == "dimbar_mode":
|
|
139
|
+
return _normalized_string(value, _VALID_DIMBAR_MODES, "viewer.dimbar_mode")
|
|
140
|
+
if section == "nninteractive" and key == "url":
|
|
141
|
+
if not isinstance(value, str) or not value.strip():
|
|
142
|
+
raise InvalidPreferenceError("nninteractive.url must be a non-empty string")
|
|
143
|
+
return value.strip()
|
|
144
|
+
raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def update_preferences(changes: dict) -> dict:
|
|
148
|
+
"""Validate and merge preferences into the latest config on disk.
|
|
149
|
+
|
|
150
|
+
``None`` removes a known preference, restoring its built-in default.
|
|
151
|
+
Unknown config sections and keys already on disk are preserved.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(changes, dict) or not changes:
|
|
154
|
+
raise InvalidPreferenceError("Preference changes must be a non-empty object")
|
|
155
|
+
validated: dict[str, dict] = {}
|
|
156
|
+
for section, values in changes.items():
|
|
157
|
+
if section not in PREFERENCE_SCHEMA or not isinstance(values, dict):
|
|
158
|
+
raise InvalidPreferenceError(f"Unknown preference section: {section}")
|
|
159
|
+
if not values:
|
|
160
|
+
raise InvalidPreferenceError(f"Preference section is empty: {section}")
|
|
161
|
+
validated[section] = {}
|
|
162
|
+
for key, value in values.items():
|
|
163
|
+
if key not in PREFERENCE_SCHEMA[section]:
|
|
164
|
+
raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
|
|
165
|
+
validated[section][key] = None if value is None else _validate_preference(section, key, value)
|
|
166
|
+
|
|
167
|
+
with _CONFIG_LOCK:
|
|
168
|
+
config = _load_config_strict()
|
|
169
|
+
merged = deepcopy(config)
|
|
170
|
+
for section, values in validated.items():
|
|
171
|
+
target = merged.get(section)
|
|
172
|
+
if target is None:
|
|
173
|
+
target = {}
|
|
174
|
+
merged[section] = target
|
|
175
|
+
if not isinstance(target, dict):
|
|
176
|
+
raise MalformedConfigError(f"Config section [{section}] is not a table")
|
|
177
|
+
for key, value in values.items():
|
|
178
|
+
if value is None:
|
|
179
|
+
target.pop(key, None)
|
|
180
|
+
else:
|
|
181
|
+
target[key] = value
|
|
182
|
+
if not target:
|
|
183
|
+
merged.pop(section, None)
|
|
184
|
+
save_config(merged)
|
|
185
|
+
return merged
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_preferences() -> dict:
|
|
189
|
+
"""Return only supported preferences currently present in the config."""
|
|
190
|
+
config = load_config()
|
|
191
|
+
result: dict[str, dict] = {}
|
|
192
|
+
for section, keys in PREFERENCE_SCHEMA.items():
|
|
193
|
+
source = config.get(section)
|
|
194
|
+
if not isinstance(source, dict):
|
|
195
|
+
continue
|
|
196
|
+
selected = {key: source[key] for key in keys if key in source}
|
|
197
|
+
if selected:
|
|
198
|
+
result[section] = selected
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_viewer_colormaps() -> list[str] | None:
|
|
203
|
+
"""Return user-configured colormap cycle list, or None if not configured."""
|
|
204
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
205
|
+
if not isinstance(viewer_cfg, dict):
|
|
206
|
+
return None
|
|
207
|
+
colormaps = viewer_cfg.get("colormaps")
|
|
208
|
+
if isinstance(colormaps, list) and all(isinstance(c, str) for c in colormaps) and colormaps:
|
|
209
|
+
return colormaps
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_viewer_theme() -> str | None:
|
|
214
|
+
"""Return user-configured default theme name, or None if not configured."""
|
|
215
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
216
|
+
if not isinstance(viewer_cfg, dict):
|
|
217
|
+
return None
|
|
218
|
+
theme = viewer_cfg.get("theme")
|
|
219
|
+
if isinstance(theme, str) and theme.strip().lower() in _VALID_THEMES:
|
|
220
|
+
return theme.strip().lower()
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_viewer_rounded_panes() -> bool | None:
|
|
225
|
+
"""Return user-configured default for rounded panes, or None if not configured."""
|
|
226
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
227
|
+
if not isinstance(viewer_cfg, dict):
|
|
228
|
+
return None
|
|
229
|
+
val = viewer_cfg.get("rounded_panes")
|
|
230
|
+
if isinstance(val, bool):
|
|
231
|
+
return val
|
|
232
|
+
if isinstance(val, str):
|
|
233
|
+
s = val.strip().lower()
|
|
234
|
+
if s in ("true", "1", "yes", "on"):
|
|
235
|
+
return True
|
|
236
|
+
if s in ("false", "0", "no", "off"):
|
|
237
|
+
return False
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_viewer_ortho_layout() -> str | None:
|
|
242
|
+
"""Return the preferred initial ortho-view layout."""
|
|
243
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
244
|
+
if not isinstance(viewer_cfg, dict):
|
|
245
|
+
return None
|
|
246
|
+
value = viewer_cfg.get("ortho_layout")
|
|
247
|
+
return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_ORTHO_LAYOUTS else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_viewer_dimbar_mode() -> str | None:
|
|
251
|
+
"""Return the preferred initial dimbar mode."""
|
|
252
|
+
viewer_cfg = load_config().get("viewer", {})
|
|
253
|
+
if not isinstance(viewer_cfg, dict):
|
|
254
|
+
return None
|
|
255
|
+
value = viewer_cfg.get("dimbar_mode")
|
|
256
|
+
return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_DIMBAR_MODES else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_nninteractive_url() -> str | None:
|
|
260
|
+
"""Return configured nnInteractive server URL, or None."""
|
|
261
|
+
env_val = os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip()
|
|
262
|
+
if env_val:
|
|
263
|
+
return env_val
|
|
264
|
+
nn_cfg = load_config().get("nninteractive", {})
|
|
265
|
+
if isinstance(nn_cfg, dict):
|
|
266
|
+
url = nn_cfg.get("url")
|
|
267
|
+
if isinstance(url, str) and url.strip():
|
|
268
|
+
return url.strip()
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_window_default(environment: str) -> str | None:
|
|
273
|
+
"""Return the user's preferred window mode for the given environment."""
|
|
274
|
+
env_val = os.environ.get("ARRAYVIEW_WINDOW", "").strip().lower()
|
|
275
|
+
if env_val:
|
|
276
|
+
return env_val if env_val in _VALID_WINDOW_MODES else None
|
|
277
|
+
window_cfg = load_config().get("window", {})
|
|
278
|
+
if not isinstance(window_cfg, dict):
|
|
279
|
+
return None
|
|
280
|
+
val = window_cfg.get(environment) or window_cfg.get("default")
|
|
281
|
+
if isinstance(val, str) and val.strip().lower() in _VALID_WINDOW_MODES:
|
|
282
|
+
return val.strip().lower()
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_toml(text: str) -> dict:
|
|
287
|
+
import tomllib
|
|
288
|
+
|
|
289
|
+
return tomllib.loads(text)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _toml_key(value: str) -> str:
|
|
293
|
+
if value and all(
|
|
294
|
+
char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
|
295
|
+
for char in value
|
|
296
|
+
):
|
|
297
|
+
return value
|
|
298
|
+
return _toml_string(value)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _toml_string(value: str) -> str:
|
|
302
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
303
|
+
escaped = escaped.replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n")
|
|
304
|
+
escaped = escaped.replace("\f", "\\f").replace("\r", "\\r")
|
|
305
|
+
return f'"{escaped}"'
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _toml_value(value) -> str:
|
|
309
|
+
if isinstance(value, bool):
|
|
310
|
+
return "true" if value else "false"
|
|
311
|
+
if isinstance(value, str):
|
|
312
|
+
return _toml_string(value)
|
|
313
|
+
if isinstance(value, int):
|
|
314
|
+
return str(value)
|
|
315
|
+
if isinstance(value, float):
|
|
316
|
+
if not math.isfinite(value):
|
|
317
|
+
return str(value).lower()
|
|
318
|
+
return repr(value)
|
|
319
|
+
if isinstance(value, (_datetime.datetime, _datetime.date, _datetime.time)):
|
|
320
|
+
return value.isoformat()
|
|
321
|
+
if isinstance(value, list):
|
|
322
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
323
|
+
raise TypeError(f"Unsupported TOML value type: {type(value).__name__}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _dump_toml(config: dict) -> str:
|
|
327
|
+
"""Serialize nested dictionaries and scalar/list TOML values."""
|
|
328
|
+
lines: list[str] = []
|
|
329
|
+
|
|
330
|
+
def emit_table(path: tuple[str, ...], table: dict) -> None:
|
|
331
|
+
scalar_items = [(key, value) for key, value in table.items() if not isinstance(value, dict)]
|
|
332
|
+
child_items = [(key, value) for key, value in table.items() if isinstance(value, dict)]
|
|
333
|
+
if path:
|
|
334
|
+
lines.append("[" + ".".join(_toml_key(part) for part in path) + "]")
|
|
335
|
+
for key, value in scalar_items:
|
|
336
|
+
lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
|
|
337
|
+
if path and (scalar_items or not child_items):
|
|
338
|
+
lines.append("")
|
|
339
|
+
for key, value in child_items:
|
|
340
|
+
emit_table((*path, str(key)), value)
|
|
341
|
+
|
|
342
|
+
if not isinstance(config, dict):
|
|
343
|
+
raise TypeError("Config must be a dictionary")
|
|
344
|
+
root_scalars = {key: value for key, value in config.items() if not isinstance(value, dict)}
|
|
345
|
+
root_tables = {key: value for key, value in config.items() if isinstance(value, dict)}
|
|
346
|
+
for key, value in root_scalars.items():
|
|
347
|
+
lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
|
|
348
|
+
if root_scalars and root_tables:
|
|
349
|
+
lines.append("")
|
|
350
|
+
for key, value in root_tables.items():
|
|
351
|
+
emit_table((str(key),), value)
|
|
352
|
+
return "\n".join(lines).rstrip() + ("\n" if lines else "")
|
|
@@ -5031,6 +5031,11 @@ def arrayview():
|
|
|
5031
5031
|
action="store_true",
|
|
5032
5032
|
help="Enable verbose output (internal status messages)",
|
|
5033
5033
|
)
|
|
5034
|
+
parser.add_argument(
|
|
5035
|
+
"--trace",
|
|
5036
|
+
action="store_true",
|
|
5037
|
+
help="Show full tracebacks instead of user-friendly errors",
|
|
5038
|
+
)
|
|
5034
5039
|
parser.add_argument(
|
|
5035
5040
|
"--diagnose",
|
|
5036
5041
|
action="store_true",
|
|
@@ -5695,24 +5700,55 @@ def arrayview():
|
|
|
5695
5700
|
"[ArrayView] --window native is not supported on remote tunnel; using vscode instead."
|
|
5696
5701
|
)
|
|
5697
5702
|
|
|
5698
|
-
|
|
5699
|
-
|
|
5703
|
+
try:
|
|
5704
|
+
if execution_registration is Registration.HTTP_LOAD:
|
|
5705
|
+
_handle_cli_existing_server(
|
|
5706
|
+
port=args.port,
|
|
5707
|
+
base_file=base_file,
|
|
5708
|
+
name=name,
|
|
5709
|
+
compare_files=compare_files,
|
|
5710
|
+
overlay_files=[] if args.stack_mode else file_overlay_paths,
|
|
5711
|
+
overlay_names=[] if args.stack_mode else file_overlay_names,
|
|
5712
|
+
rgb=args.rgb,
|
|
5713
|
+
vectorfield=args.vectorfield,
|
|
5714
|
+
vfield_components_dim=vfield_components_dim,
|
|
5715
|
+
use_native_shell=use_native_shell,
|
|
5716
|
+
dims_override=dims_override,
|
|
5717
|
+
watch=getattr(args, "watch", False),
|
|
5718
|
+
window_mode=window_mode,
|
|
5719
|
+
floating=args.floating,
|
|
5720
|
+
is_remote=is_remote,
|
|
5721
|
+
launch_context=launch_context,
|
|
5722
|
+
dir_patterns=dir_patterns,
|
|
5723
|
+
dir_overlay_specs=dir_overlay_specs,
|
|
5724
|
+
dir_case_regex=args.case_regex,
|
|
5725
|
+
dir_exclude_cases=sorted(excluded_cases),
|
|
5726
|
+
collection_load=args.load,
|
|
5727
|
+
collection_stack=args.stack_policy or "auto",
|
|
5728
|
+
)
|
|
5729
|
+
return
|
|
5730
|
+
|
|
5731
|
+
demo_name = getattr(args, "_demo_name", None)
|
|
5732
|
+
demo_cleanup = getattr(args, "_demo_cleanup", False)
|
|
5733
|
+
_handle_cli_spawned_daemon(
|
|
5700
5734
|
port=args.port,
|
|
5701
5735
|
base_file=base_file,
|
|
5702
5736
|
name=name,
|
|
5703
5737
|
compare_files=compare_files,
|
|
5704
5738
|
overlay_files=[] if args.stack_mode else file_overlay_paths,
|
|
5705
5739
|
overlay_names=[] if args.stack_mode else file_overlay_names,
|
|
5706
|
-
rgb=args.rgb,
|
|
5707
|
-
vectorfield=args.vectorfield,
|
|
5708
|
-
vfield_components_dim=vfield_components_dim,
|
|
5709
|
-
use_native_shell=use_native_shell,
|
|
5710
5740
|
dims_override=dims_override,
|
|
5741
|
+
use_native_shell=use_native_shell,
|
|
5711
5742
|
watch=getattr(args, "watch", False),
|
|
5712
5743
|
window_mode=window_mode,
|
|
5713
5744
|
floating=args.floating,
|
|
5714
5745
|
is_remote=is_remote,
|
|
5715
5746
|
launch_context=launch_context,
|
|
5747
|
+
vectorfield=args.vectorfield,
|
|
5748
|
+
vfield_components_dim=vfield_components_dim,
|
|
5749
|
+
rgb=args.rgb,
|
|
5750
|
+
demo_name=demo_name,
|
|
5751
|
+
demo_cleanup=demo_cleanup,
|
|
5716
5752
|
dir_patterns=dir_patterns,
|
|
5717
5753
|
dir_overlay_specs=dir_overlay_specs,
|
|
5718
5754
|
dir_case_regex=args.case_regex,
|
|
@@ -5720,33 +5756,9 @@ def arrayview():
|
|
|
5720
5756
|
collection_load=args.load,
|
|
5721
5757
|
collection_stack=args.stack_policy or "auto",
|
|
5722
5758
|
)
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
base_file=base_file,
|
|
5730
|
-
name=name,
|
|
5731
|
-
compare_files=compare_files,
|
|
5732
|
-
overlay_files=[] if args.stack_mode else file_overlay_paths,
|
|
5733
|
-
overlay_names=[] if args.stack_mode else file_overlay_names,
|
|
5734
|
-
dims_override=dims_override,
|
|
5735
|
-
use_native_shell=use_native_shell,
|
|
5736
|
-
watch=getattr(args, "watch", False),
|
|
5737
|
-
window_mode=window_mode,
|
|
5738
|
-
floating=args.floating,
|
|
5739
|
-
is_remote=is_remote,
|
|
5740
|
-
launch_context=launch_context,
|
|
5741
|
-
vectorfield=args.vectorfield,
|
|
5742
|
-
vfield_components_dim=vfield_components_dim,
|
|
5743
|
-
rgb=args.rgb,
|
|
5744
|
-
demo_name=demo_name,
|
|
5745
|
-
demo_cleanup=demo_cleanup,
|
|
5746
|
-
dir_patterns=dir_patterns,
|
|
5747
|
-
dir_overlay_specs=dir_overlay_specs,
|
|
5748
|
-
dir_case_regex=args.case_regex,
|
|
5749
|
-
dir_exclude_cases=sorted(excluded_cases),
|
|
5750
|
-
collection_load=args.load,
|
|
5751
|
-
collection_stack=args.stack_policy or "auto",
|
|
5752
|
-
)
|
|
5759
|
+
except Exception as e:
|
|
5760
|
+
if args.trace or os.environ.get("ARRAYVIEW_TRACE"):
|
|
5761
|
+
raise
|
|
5762
|
+
msg = str(e).removeprefix("[ArrayView] ").rstrip()
|
|
5763
|
+
print(f"\033[1;31m[ArrayView] {msg}\033[0m", file=sys.stderr)
|
|
5764
|
+
sys.exit(1)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Routes for reading and updating persistent user preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from arrayview._config import (
|
|
12
|
+
BUILTIN_PREFERENCES,
|
|
13
|
+
InvalidPreferenceError,
|
|
14
|
+
MalformedConfigError,
|
|
15
|
+
PREFERENCE_SCHEMA,
|
|
16
|
+
get_preferences,
|
|
17
|
+
update_preferences,
|
|
18
|
+
)
|
|
19
|
+
import arrayview._session as _session_mod
|
|
20
|
+
from arrayview._session import SESSIONS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _response_payload() -> dict:
|
|
24
|
+
defaults = deepcopy(BUILTIN_PREFERENCES)
|
|
25
|
+
schema = deepcopy(PREFERENCE_SCHEMA)
|
|
26
|
+
for key, options in schema["window"].items():
|
|
27
|
+
schema["window"][key] = [value for value in options if value != "none"]
|
|
28
|
+
schema["window"].pop("default", None)
|
|
29
|
+
try:
|
|
30
|
+
from arrayview._platform import _is_vscode_remote, _native_window_gui
|
|
31
|
+
|
|
32
|
+
native_available = _native_window_gui() is not None
|
|
33
|
+
vscode_remote = _is_vscode_remote()
|
|
34
|
+
except Exception:
|
|
35
|
+
native_available = False
|
|
36
|
+
vscode_remote = False
|
|
37
|
+
if not native_available:
|
|
38
|
+
defaults["window"]["default"] = "browser"
|
|
39
|
+
defaults["window"]["terminal"] = "browser"
|
|
40
|
+
for key in ("default", "terminal", "vscode", "jupyter", "julia"):
|
|
41
|
+
schema["window"][key] = [
|
|
42
|
+
value for value in schema["window"][key] if value != "native"
|
|
43
|
+
]
|
|
44
|
+
if vscode_remote:
|
|
45
|
+
schema["window"]["vscode"] = ["vscode", "none"]
|
|
46
|
+
|
|
47
|
+
overrides = {}
|
|
48
|
+
if os.environ.get("ARRAYVIEW_WINDOW", "").strip():
|
|
49
|
+
overrides["window"] = "ARRAYVIEW_WINDOW"
|
|
50
|
+
if os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip():
|
|
51
|
+
overrides["nninteractive.url"] = "ARRAYVIEW_NNINTERACTIVE_URL"
|
|
52
|
+
return {
|
|
53
|
+
"preferences": get_preferences(),
|
|
54
|
+
"defaults": defaults,
|
|
55
|
+
"schema": schema,
|
|
56
|
+
"overrides": overrides,
|
|
57
|
+
"server_instance_id": _session_mod.SERVER_RUNTIME.instance_id,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def register_preferences_routes(app) -> None:
|
|
62
|
+
@app.get("/preferences/{sid}")
|
|
63
|
+
async def preferences_get(sid: str):
|
|
64
|
+
if SESSIONS.get(sid) is None:
|
|
65
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
66
|
+
return _response_payload()
|
|
67
|
+
|
|
68
|
+
@app.patch("/preferences/{sid}")
|
|
69
|
+
async def preferences_patch(sid: str, request: Request):
|
|
70
|
+
if SESSIONS.get(sid) is None:
|
|
71
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
72
|
+
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
73
|
+
if content_type != "application/json":
|
|
74
|
+
return JSONResponse({"error": "json_content_type_required"}, status_code=415)
|
|
75
|
+
if request.headers.get("x-arrayview-preferences") != "1":
|
|
76
|
+
return JSONResponse({"error": "preferences_header_required"}, status_code=403)
|
|
77
|
+
try:
|
|
78
|
+
body = await request.json()
|
|
79
|
+
except Exception:
|
|
80
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
81
|
+
if not isinstance(body, dict):
|
|
82
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
83
|
+
if body.get("server_instance_id") != _session_mod.SERVER_RUNTIME.instance_id:
|
|
84
|
+
return JSONResponse({"error": "stale_server_instance"}, status_code=409)
|
|
85
|
+
changes = body.get("changes")
|
|
86
|
+
try:
|
|
87
|
+
viewer_changes = changes.get("viewer", {}) if isinstance(changes, dict) else {}
|
|
88
|
+
colormaps = viewer_changes.get("colormaps")
|
|
89
|
+
if colormaps is not None:
|
|
90
|
+
from arrayview._render import _ensure_lut
|
|
91
|
+
|
|
92
|
+
invalid = [
|
|
93
|
+
name for name in colormaps
|
|
94
|
+
if not isinstance(name, str) or not _ensure_lut(name)
|
|
95
|
+
]
|
|
96
|
+
if invalid:
|
|
97
|
+
raise InvalidPreferenceError(f"Unknown colormap: {invalid[0]}")
|
|
98
|
+
update_preferences(changes)
|
|
99
|
+
except InvalidPreferenceError as exc:
|
|
100
|
+
return JSONResponse(
|
|
101
|
+
{"error": "invalid_preference", "detail": str(exc)},
|
|
102
|
+
status_code=422,
|
|
103
|
+
)
|
|
104
|
+
except MalformedConfigError as exc:
|
|
105
|
+
return JSONResponse(
|
|
106
|
+
{"error": "malformed_config", "detail": str(exc)},
|
|
107
|
+
status_code=409,
|
|
108
|
+
)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
{"error": "config_write_failed", "detail": str(exc)},
|
|
112
|
+
status_code=500,
|
|
113
|
+
)
|
|
114
|
+
return _response_payload()
|
|
@@ -41,6 +41,7 @@ from arrayview._routes_analysis import register_analysis_routes
|
|
|
41
41
|
from arrayview._routes_loading import register_loading_routes
|
|
42
42
|
from arrayview._routes_drop import register_drop_routes
|
|
43
43
|
from arrayview._routes_persistence import register_persistence_routes
|
|
44
|
+
from arrayview._routes_preferences import register_preferences_routes
|
|
44
45
|
from arrayview._routes_export import register_export_routes
|
|
45
46
|
from arrayview._routes_preload import register_preload_routes
|
|
46
47
|
from arrayview._routes_query import register_query_routes
|
|
@@ -49,7 +50,13 @@ from arrayview._routes_segmentation import register_segmentation_routes
|
|
|
49
50
|
from arrayview._routes_state import register_state_routes
|
|
50
51
|
from arrayview._routes_vectorfield import register_vectorfield_routes
|
|
51
52
|
from arrayview._routes_websocket import _notify_shells, register_websocket_routes
|
|
52
|
-
from arrayview._config import
|
|
53
|
+
from arrayview._config import (
|
|
54
|
+
get_viewer_colormaps,
|
|
55
|
+
get_viewer_dimbar_mode,
|
|
56
|
+
get_viewer_ortho_layout,
|
|
57
|
+
get_viewer_rounded_panes,
|
|
58
|
+
get_viewer_theme,
|
|
59
|
+
)
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
from arrayview._imaging import ensure_image as _pil_image, ensure_imageops as _pil_imageops
|
|
@@ -176,6 +183,7 @@ register_websocket_routes(app)
|
|
|
176
183
|
register_loading_routes(app, notify_shells=_notify_shells, setup_rgb=_setup_rgb)
|
|
177
184
|
register_drop_routes(app)
|
|
178
185
|
register_persistence_routes(app)
|
|
186
|
+
register_preferences_routes(app)
|
|
179
187
|
register_segmentation_routes(app, get_session_or_404)
|
|
180
188
|
register_state_routes(app, get_session_or_404)
|
|
181
189
|
register_export_routes(app, get_session_or_404=get_session_or_404, pil_image=_pil_image)
|
|
@@ -287,12 +295,19 @@ def get_ui(sid: str = None):
|
|
|
287
295
|
query_val = "null"
|
|
288
296
|
_init_luts()
|
|
289
297
|
_cfg_colormaps = get_viewer_colormaps()
|
|
290
|
-
|
|
298
|
+
_valid_cfg_colormaps = (
|
|
299
|
+
[name for name in _cfg_colormaps if _ensure_lut(name)]
|
|
300
|
+
if _cfg_colormaps is not None
|
|
301
|
+
else []
|
|
302
|
+
)
|
|
303
|
+
_active_colormaps = _valid_cfg_colormaps or COLORMAPS
|
|
291
304
|
_theme_names = ["dark", "light"]
|
|
292
305
|
_cfg_theme = get_viewer_theme()
|
|
293
306
|
_default_theme_idx = _theme_names.index(_cfg_theme) if _cfg_theme in _theme_names else 0
|
|
294
307
|
_cfg_rounded = get_viewer_rounded_panes()
|
|
295
308
|
_default_rounded_panes = "false" if _cfg_rounded is False else "true"
|
|
309
|
+
_default_ortho_layout = json.dumps(get_viewer_ortho_layout())
|
|
310
|
+
_default_dimbar_mode = json.dumps(get_viewer_dimbar_mode())
|
|
296
311
|
html = (
|
|
297
312
|
_VIEWER_HTML_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
|
|
298
313
|
.replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
|
|
@@ -302,6 +317,8 @@ def get_ui(sid: str = None):
|
|
|
302
317
|
.replace("__ARRAYVIEW_QUERY__", query_val)
|
|
303
318
|
.replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
|
|
304
319
|
.replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
|
|
320
|
+
.replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
|
|
321
|
+
.replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
|
|
305
322
|
.replace("__BODY_CLASS__", "av-loading" if sid else "")
|
|
306
323
|
.replace("__ARRAYVIEW_VERSION__", _av_version)
|
|
307
324
|
)
|