ivoryos 1.2.5__py3-none-any.whl → 1.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. docs/source/conf.py +84 -0
  2. ivoryos/__init__.py +16 -246
  3. ivoryos/app.py +154 -0
  4. ivoryos/optimizer/ax_optimizer.py +55 -28
  5. ivoryos/optimizer/base_optimizer.py +20 -1
  6. ivoryos/optimizer/baybe_optimizer.py +27 -17
  7. ivoryos/optimizer/nimo_optimizer.py +173 -0
  8. ivoryos/optimizer/registry.py +3 -1
  9. ivoryos/routes/auth/auth.py +35 -8
  10. ivoryos/routes/auth/templates/change_password.html +32 -0
  11. ivoryos/routes/control/control.py +58 -28
  12. ivoryos/routes/control/control_file.py +12 -15
  13. ivoryos/routes/control/control_new_device.py +21 -11
  14. ivoryos/routes/control/templates/controllers.html +27 -0
  15. ivoryos/routes/control/utils.py +2 -0
  16. ivoryos/routes/data/data.py +110 -44
  17. ivoryos/routes/data/templates/components/step_card.html +78 -13
  18. ivoryos/routes/data/templates/workflow_view.html +343 -113
  19. ivoryos/routes/design/design.py +59 -10
  20. ivoryos/routes/design/design_file.py +3 -3
  21. ivoryos/routes/design/design_step.py +43 -17
  22. ivoryos/routes/design/templates/components/action_form.html +2 -2
  23. ivoryos/routes/design/templates/components/canvas_main.html +6 -1
  24. ivoryos/routes/design/templates/components/edit_action_form.html +18 -3
  25. ivoryos/routes/design/templates/components/info_modal.html +318 -0
  26. ivoryos/routes/design/templates/components/instruments_panel.html +23 -1
  27. ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
  28. ivoryos/routes/design/templates/experiment_builder.html +3 -0
  29. ivoryos/routes/execute/execute.py +82 -22
  30. ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
  31. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  32. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  33. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  34. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  35. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  36. ivoryos/routes/library/library.py +9 -11
  37. ivoryos/routes/main/main.py +30 -2
  38. ivoryos/server.py +180 -0
  39. ivoryos/socket_handlers.py +1 -1
  40. ivoryos/static/ivoryos_logo.png +0 -0
  41. ivoryos/static/js/action_handlers.js +259 -88
  42. ivoryos/static/js/socket_handler.js +40 -5
  43. ivoryos/static/js/sortable_design.js +29 -11
  44. ivoryos/templates/base.html +61 -2
  45. ivoryos/utils/bo_campaign.py +18 -17
  46. ivoryos/utils/client_proxy.py +267 -36
  47. ivoryos/utils/db_models.py +286 -60
  48. ivoryos/utils/decorators.py +34 -0
  49. ivoryos/utils/form.py +52 -19
  50. ivoryos/utils/global_config.py +21 -0
  51. ivoryos/utils/nest_script.py +314 -0
  52. ivoryos/utils/py_to_json.py +80 -10
  53. ivoryos/utils/script_runner.py +573 -189
  54. ivoryos/utils/task_runner.py +69 -22
  55. ivoryos/utils/utils.py +48 -5
  56. ivoryos/version.py +1 -1
  57. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/METADATA +109 -47
  58. ivoryos-1.4.4.dist-info/RECORD +119 -0
  59. ivoryos-1.4.4.dist-info/top_level.txt +3 -0
  60. tests/__init__.py +0 -0
  61. tests/conftest.py +133 -0
  62. tests/integration/__init__.py +0 -0
  63. tests/integration/test_route_auth.py +80 -0
  64. tests/integration/test_route_control.py +94 -0
  65. tests/integration/test_route_database.py +61 -0
  66. tests/integration/test_route_design.py +36 -0
  67. tests/integration/test_route_main.py +35 -0
  68. tests/integration/test_sockets.py +26 -0
  69. tests/unit/test_type_conversion.py +42 -0
  70. tests/unit/test_util.py +3 -0
  71. ivoryos/routes/api/api.py +0 -56
  72. ivoryos-1.2.5.dist-info/RECORD +0 -100
  73. ivoryos-1.2.5.dist-info/top_level.txt +0 -1
  74. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/WHEEL +0 -0
  75. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,9 @@
1
- from flask import Blueprint, render_template, current_app
2
- from flask_login import login_required
1
+ import os
2
+
3
+ from flask import Blueprint, render_template, current_app, request, url_for
4
+ from flask_login import login_required, current_user
5
+ from werkzeug.utils import secure_filename, redirect
6
+ from ivoryos.utils.db_models import db
3
7
  from ivoryos.version import __version__ as ivoryos_version
4
8
 
5
9
  main = Blueprint('main', __name__, template_folder='templates')
@@ -40,3 +44,27 @@ def help_info():
40
44
  ivoryos(__name__)
41
45
  """
42
46
  return render_template('help.html', sample_deck=sample_deck)
47
+
48
+
49
+ @main.route('/customize-logo', methods=['POST'])
50
+ @login_required
51
+ def customize_logo():
52
+ if request.method == 'POST':
53
+ file = request.files.get('logo')
54
+ mode = request.form.get('mode')
55
+
56
+ if file and file.filename != '':
57
+ filename = secure_filename(file.filename)
58
+
59
+ USER_LOGO_DIR = os.path.join(current_app.static_folder, "user_logos")
60
+ os.makedirs(USER_LOGO_DIR, exist_ok=True)
61
+ filepath = os.path.join(USER_LOGO_DIR, filename)
62
+ file.save(filepath)
63
+
64
+ # Save to database
65
+ current_user.settings = {"logo_filename": filename, "logo_mode": mode}
66
+ # current_user.logo_mode = mode
67
+ db.session.commit()
68
+
69
+ return redirect(url_for('main.index'))
70
+
ivoryos/server.py ADDED
@@ -0,0 +1,180 @@
1
+ import os
2
+ import sqlite3
3
+ import sys
4
+ from typing import Union
5
+
6
+ from flask import Blueprint
7
+
8
+ from sqlalchemy import Engine, event
9
+
10
+ # from ivoryos import BUILDING_BLOCKS
11
+ from ivoryos.app import create_app
12
+ from ivoryos.config import Config, get_config
13
+ from ivoryos.optimizer.registry import OPTIMIZER_REGISTRY
14
+ from ivoryos.routes.auth.auth import login_manager
15
+ from ivoryos.routes.control.control import global_config
16
+ from ivoryos.socket_handlers import socketio
17
+ from ivoryos.utils import utils
18
+ from ivoryos.utils.db_models import db, User
19
+
20
+
21
+ url_prefix = os.getenv('URL_PREFIX', "/ivoryos")
22
+
23
+ @event.listens_for(Engine, "connect")
24
+ def enforce_sqlite_foreign_keys(dbapi_connection, connection_record):
25
+ if isinstance(dbapi_connection, sqlite3.Connection):
26
+ cursor = dbapi_connection.cursor()
27
+ cursor.execute("PRAGMA foreign_keys=ON")
28
+ cursor.close()
29
+
30
+
31
+
32
+ @login_manager.user_loader
33
+ def load_user(user_id):
34
+ """
35
+ This function is called by Flask-Login on every request to get the
36
+ current user object from the user ID stored in the session.
37
+ """
38
+ # The correct implementation is to fetch the user from the database.
39
+ return db.session.get(User, user_id)
40
+
41
+
42
+
43
+
44
+
45
+ def run(module=None, host="0.0.0.0", port=None, debug=None, llm_server=None, model=None,
46
+ config: Config = None,
47
+ logger: Union[str, list] = None,
48
+ logger_output_name: str = None,
49
+ enable_design: bool = True,
50
+ blueprint_plugins: Union[list, Blueprint] = [],
51
+ exclude_names: list = [],
52
+ notification_handler=None,
53
+ optimizer_registry: dict = None,
54
+ ):
55
+ """
56
+ Start ivoryOS app server.
57
+
58
+ :param module: module name, __name__ for current module
59
+ :param host: host address, defaults to 0.0.0.0
60
+ :param port: port, defaults to None, and will use 8000
61
+ :param debug: debug mode, defaults to None (True)
62
+ :param llm_server: llm server, defaults to None.
63
+ :param model: llm model, defaults to None. If None, app will run without text-to-code feature
64
+ :param config: config class, defaults to None
65
+ :param logger: logger name of list of logger names, defaults to None
66
+ :param logger_output_name: log file save name of logger, defaults to None, and will use "default.log"
67
+ :param enable_design: enable design canvas, database and workflow execution
68
+ :param blueprint_plugins: Union[list[Blueprint], Blueprint] custom Blueprint pages
69
+ :param exclude_names: list[str] module names to exclude from parsing
70
+ :param notification_handler: notification handler function
71
+ """
72
+ app = create_app(config_class=config or get_config()) # Create app instance using factory function
73
+
74
+ # plugins = load_installed_plugins(app, socketio)
75
+ plugins = []
76
+ if blueprint_plugins:
77
+ config_plugins = load_plugins(blueprint_plugins, app, socketio)
78
+ plugins.extend(config_plugins)
79
+
80
+ def inject_nav_config():
81
+ """Make NAV_CONFIG available globally to all templates."""
82
+ return dict(
83
+ enable_design=enable_design,
84
+ plugins=plugins,
85
+ )
86
+
87
+ app.context_processor(inject_nav_config)
88
+ port = port or int(os.environ.get("PORT", 8000))
89
+ debug = debug if debug is not None else app.config.get('DEBUG', True)
90
+
91
+ app.config["LOGGERS"] = logger
92
+ app.config["LOGGERS_PATH"] = logger_output_name or app.config["LOGGERS_PATH"] # default.log
93
+ logger_path = os.path.join(app.config["OUTPUT_FOLDER"], app.config["LOGGERS_PATH"])
94
+ dummy_deck_path = os.path.join(app.config["OUTPUT_FOLDER"], app.config["DUMMY_DECK"])
95
+ if optimizer_registry:
96
+ global_config.optimizers = optimizer_registry
97
+ else:
98
+ global_config.optimizers = OPTIMIZER_REGISTRY
99
+ if module:
100
+ app.config["MODULE"] = module
101
+ app.config["OFF_LINE"] = False
102
+ global_config.deck = sys.modules[module]
103
+ global_config.building_blocks = utils.create_block_snapshot()
104
+ global_config.deck_snapshot = utils.create_deck_snapshot(global_config.deck,
105
+ output_path=dummy_deck_path,
106
+ save=True,
107
+ exclude_names=exclude_names
108
+ )
109
+ global_config.api_variables = utils.create_module_snapshot(global_config.deck)
110
+
111
+ else:
112
+ app.config["OFF_LINE"] = True
113
+ if model:
114
+ app.config["ENABLE_LLM"] = True
115
+ app.config["LLM_MODEL"] = model
116
+ app.config["LLM_SERVER"] = llm_server
117
+ utils.install_and_import('openai')
118
+ from ivoryos.utils.llm_agent import LlmAgent
119
+ global_config.agent = LlmAgent(host=llm_server, model=model,
120
+ output_path=app.config["OUTPUT_FOLDER"] if module is not None else None)
121
+ else:
122
+ app.config["ENABLE_LLM"] = False
123
+
124
+
125
+ # --- Logger registration ---
126
+ if logger:
127
+ if isinstance(logger, str):
128
+ logger = [logger] # convert single logger to list
129
+ elif not isinstance(logger, list):
130
+ raise TypeError("logger must be a string or a list of strings.")
131
+
132
+ for log_name in logger:
133
+ utils.start_logger(socketio, log_filename=logger_path, logger_name=log_name)
134
+
135
+ # --- Notification handler registration ---
136
+ if notification_handler:
137
+
138
+ # make it a list if a single function is passed
139
+ if callable(notification_handler):
140
+ notification_handler = [notification_handler]
141
+
142
+ if not isinstance(notification_handler, list):
143
+ raise ValueError("notification_handlers must be a callable or a list of callables.")
144
+
145
+ # validate all items are callable
146
+ for handler in notification_handler:
147
+ if not callable(handler):
148
+ raise TypeError(f"Handler {handler} is not callable.")
149
+ global_config.register_notification(handler)
150
+
151
+ # TODO in case Python 3.12 or higher doesn't log URL
152
+ # if sys.version_info >= (3, 12):
153
+ # ip = utils.get_local_ip()
154
+ # print(f"Server running at http://localhost:{port}")
155
+ # if not ip == "127.0.0.1":
156
+ # print(f"Server running at http://{ip}:{port}")
157
+ socketio.run(app, host=host, port=port, debug=debug, use_reloader=False, allow_unsafe_werkzeug=True)
158
+ # return app
159
+
160
+
161
+
162
+ def load_plugins(blueprints: Union[list, Blueprint], app, socketio):
163
+ """
164
+ Dynamically load installed plugins and attach Flask-SocketIO.
165
+ :param blueprints: Union[list, Blueprint] list of Blueprint objects or a single Blueprint object
166
+ :param app: Flask application instance
167
+ :param socketio: Flask-SocketIO instance
168
+ :return: list of plugin names
169
+ """
170
+ plugin_names = []
171
+ if not isinstance(blueprints, list):
172
+ blueprints = [blueprints]
173
+ for blueprint in blueprints:
174
+ # If the plugin has an `init_socketio()` function, pass socketio
175
+ if hasattr(blueprint, 'init_socketio'):
176
+ blueprint.init_socketio(socketio)
177
+ plugin_names.append(blueprint.name)
178
+ app.register_blueprint(blueprint, url_prefix=f"{url_prefix}/{blueprint.name}")
179
+ return plugin_names
180
+
@@ -3,7 +3,7 @@ from flask import current_app
3
3
  from flask_socketio import SocketIO
4
4
  from ivoryos.utils.script_runner import ScriptRunner
5
5
 
6
- socketio = SocketIO()
6
+ socketio = SocketIO(cors_allowed_origins="*")
7
7
  runner = ScriptRunner()
8
8
 
9
9
  def abort_pending():
Binary file
@@ -1,6 +1,148 @@
1
+ // ============================================================================
2
+ // STATE MANAGEMENT
3
+ // ============================================================================
1
4
 
5
+ let previousHtmlState = null; // Store previous instrument panel state
6
+ let lastFocusedElement = null; // Track focus for modal management
2
7
 
8
+ // ============================================================================
9
+ // MODE & BATCH MANAGEMENT
10
+ // ============================================================================
3
11
 
12
+ function getMode() {
13
+ return sessionStorage.getItem("mode") || "single";
14
+ }
15
+
16
+ function setMode(mode, triggerUpdate = true) {
17
+ sessionStorage.setItem("mode", mode);
18
+
19
+ const modeButtons = document.querySelectorAll(".mode-toggle");
20
+ const batchOptions = document.getElementById("batch-options");
21
+
22
+ modeButtons.forEach(b => b.classList.toggle("active", b.dataset.mode === mode));
23
+
24
+ if (batchOptions) {
25
+ batchOptions.style.display = (mode === "batch") ? "inline-flex" : "none";
26
+ }
27
+
28
+ if (triggerUpdate) updateCode();
29
+ }
30
+
31
+ function getBatch() {
32
+ return sessionStorage.getItem("batch") || "sample";
33
+ }
34
+
35
+ function setBatch(batch, triggerUpdate = true) {
36
+ sessionStorage.setItem("batch", batch);
37
+
38
+ const batchButtons = document.querySelectorAll(".batch-toggle");
39
+ batchButtons.forEach(b => b.classList.toggle("active", b.dataset.batch === batch));
40
+
41
+ if (triggerUpdate) updateCode();
42
+ }
43
+
44
+ // ============================================================================
45
+ // CODE OVERLAY MANAGEMENT
46
+ // ============================================================================
47
+
48
+ async function updateCode() {
49
+ try {
50
+ const params = new URLSearchParams({ mode: getMode(), batch: getBatch() });
51
+ const res = await fetch(scriptCompileUrl + "?" + params.toString());
52
+ if (!res.ok) return;
53
+
54
+ const data = await res.json();
55
+ const codeElem = document.getElementById("python-code");
56
+
57
+ codeElem.removeAttribute("data-highlighted"); // Reset highlight.js flag
58
+ codeElem.textContent = data.code['script'] || "# No code found";
59
+
60
+ if (window.hljs) {
61
+ hljs.highlightElement(codeElem);
62
+ }
63
+ } catch (err) {
64
+ console.error("Error updating code:", err);
65
+ }
66
+ }
67
+
68
+ function initializeCodeOverlay() {
69
+ const codeElem = document.getElementById("python-code");
70
+ const copyBtn = document.getElementById("copy-code");
71
+ const downloadBtn = document.getElementById("download-code");
72
+
73
+ if (!copyBtn || !downloadBtn) return; // Elements don't exist
74
+
75
+ // Remove old listeners by cloning (prevents duplicate bindings)
76
+ const newCopyBtn = copyBtn.cloneNode(true);
77
+ const newDownloadBtn = downloadBtn.cloneNode(true);
78
+ copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
79
+ downloadBtn.parentNode.replaceChild(newDownloadBtn, downloadBtn);
80
+
81
+ // Copy to clipboard
82
+ newCopyBtn.addEventListener("click", () => {
83
+ navigator.clipboard.writeText(codeElem.textContent)
84
+ .then(() => alert("Code copied!"))
85
+ .catch(err => console.error("Failed to copy", err));
86
+ });
87
+
88
+ // Download current code
89
+ newDownloadBtn.addEventListener("click", () => {
90
+ const blob = new Blob([codeElem.textContent], { type: "text/plain" });
91
+ const url = URL.createObjectURL(blob);
92
+ const a = document.createElement("a");
93
+ a.href = url;
94
+ a.download = "script.py";
95
+ a.click();
96
+ URL.revokeObjectURL(url);
97
+ });
98
+ updateCode();
99
+ }
100
+
101
+ // ============================================================================
102
+ // UI UPDATE FUNCTIONS
103
+ // ============================================================================
104
+
105
+ function updateActionCanvas(html) {
106
+ document.getElementById("canvas-action-wrapper").innerHTML = html;
107
+ initializeCanvas();
108
+
109
+ const mode = getMode();
110
+ const batch = getBatch();
111
+
112
+ // Rebind event handlers for mode/batch toggles
113
+ document.querySelectorAll(".mode-toggle").forEach(btn => {
114
+ btn.addEventListener("click", () => setMode(btn.dataset.mode));
115
+ });
116
+ document.querySelectorAll(".batch-toggle").forEach(btn => {
117
+ btn.addEventListener("click", () => setBatch(btn.dataset.batch));
118
+ });
119
+
120
+ // Restore toggle UI state (without triggering updates)
121
+ setMode(mode, false);
122
+ setBatch(batch, false);
123
+
124
+ // Reinitialize code overlay buttons
125
+ initializeCodeOverlay();
126
+
127
+ }
128
+
129
+ function updateInstrumentPanel(link) {
130
+ const url = link.dataset.getUrl;
131
+
132
+ fetch(url)
133
+ .then(res => res.json())
134
+ .then(data => {
135
+ if (data.html) {
136
+ document.getElementById("sidebar-wrapper").innerHTML = data.html;
137
+ initializeDragHandlers();
138
+ }
139
+ })
140
+ .catch(err => console.error("Error updating instrument panel:", err));
141
+ }
142
+
143
+ // ============================================================================
144
+ // WORKFLOW MANAGEMENT
145
+ // ============================================================================
4
146
 
5
147
  function saveWorkflow(link) {
6
148
  const url = link.dataset.postUrl;
@@ -14,9 +156,7 @@ function saveWorkflow(link) {
14
156
  .then(res => res.json())
15
157
  .then(data => {
16
158
  if (data.success) {
17
- // flash a success message
18
- flash("Workflow saved successfully", "success");
19
- window.location.reload(); // or update the UI dynamically
159
+ window.location.reload();
20
160
  } else {
21
161
  alert("Failed to save workflow: " + data.error);
22
162
  }
@@ -27,21 +167,30 @@ function saveWorkflow(link) {
27
167
  });
28
168
  }
29
169
 
30
-
31
- function updateInstrumentPanel(link) {
32
- const url = link.dataset.getUrl;
33
- fetch(url)
170
+ function clearDraft() {
171
+ fetch(scriptDeleteUrl, {
172
+ method: "DELETE",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ },
176
+ })
34
177
  .then(res => res.json())
35
178
  .then(data => {
36
- if (data.html) {
37
- document.getElementById("sidebar-wrapper").innerHTML = data.html;
38
- initializeDragHandlers()
179
+ if (data.success) {
180
+ window.location.reload();
181
+ } else {
182
+ alert("Failed to clear draft");
39
183
  }
40
184
  })
185
+ .catch(error => console.error("Failed to clear draft", error));
41
186
  }
42
187
 
188
+ // ============================================================================
189
+ // ACTION MANAGEMENT (CRUD Operations)
190
+ // ============================================================================
191
+
43
192
  function addMethodToDesign(event, form) {
44
- event.preventDefault(); // Prevent default form submission
193
+ event.preventDefault();
45
194
 
46
195
  const formData = new FormData(form);
47
196
 
@@ -61,31 +210,58 @@ function addMethodToDesign(event, form) {
61
210
  .catch(error => console.error('Error:', error));
62
211
  }
63
212
 
213
+ function editAction(uuid) {
214
+ if (!uuid) {
215
+ console.error('Invalid UUID');
216
+ return;
217
+ }
64
218
 
65
- function updateActionCanvas(html) {
66
- document.getElementById("canvas-action-wrapper").innerHTML = html;
67
- initializeCanvas(); // Reinitialize canvas functionality
68
- document.querySelectorAll('#pythonCodeOverlay pre code').forEach((block) => {
69
- hljs.highlightElement(block);
70
- });
71
- }
72
-
73
-
74
- let lastFocusedElement = null;
219
+ // Store current state for rollback
220
+ previousHtmlState = document.getElementById('instrument-panel').innerHTML;
75
221
 
222
+ fetch(scriptStepUrl.replace('0', uuid), {
223
+ method: 'GET',
224
+ headers: {
225
+ 'Content-Type': 'application/json'
226
+ }
227
+ })
228
+ .then(response => {
229
+ if (!response.ok) {
230
+ return response.json().then(err => {
231
+ if (err.warning) {
232
+ alert(err.warning);
233
+ }
234
+ // Restore panel so user isn't stuck
235
+ if (previousHtmlState) {
236
+ document.getElementById('instrument-panel').innerHTML = previousHtmlState;
237
+ previousHtmlState = null;
238
+ }
239
+ throw new Error("Step fetch failed: " + response.status);
240
+ });
241
+ }
242
+ return response.text();
243
+ })
244
+ .then(html => {
245
+ document.getElementById('instrument-panel').innerHTML = html;
76
246
 
77
- function hideModal() {
78
- if (document.activeElement) {
79
- document.activeElement.blur();
80
- }
81
- $('#dropModal').modal('hide');
82
- if (lastFocusedElement) {
83
- lastFocusedElement.focus(); // Return focus to the triggering element
84
- }
247
+ // Set up back button
248
+ const backButton = document.getElementById('back');
249
+ if (backButton) {
250
+ backButton.addEventListener('click', function(e) {
251
+ e.preventDefault();
252
+ if (previousHtmlState) {
253
+ document.getElementById('instrument-panel').innerHTML = previousHtmlState;
254
+ previousHtmlState = null;
255
+ }
256
+ });
257
+ }
258
+ })
259
+ .catch(error => console.error('Error:', error));
85
260
  }
86
261
 
87
262
  function submitEditForm(event) {
88
263
  event.preventDefault();
264
+
89
265
  const form = event.target;
90
266
  const formData = new FormData(form);
91
267
 
@@ -96,43 +272,21 @@ function submitEditForm(event) {
96
272
  .then(response => response.text())
97
273
  .then(html => {
98
274
  if (html) {
99
- // Update only the action list
100
275
  updateActionCanvas(html);
101
276
 
277
+ // Restore previous instrument panel state
102
278
  if (previousHtmlState) {
103
279
  document.getElementById('instrument-panel').innerHTML = previousHtmlState;
104
- previousHtmlState = null; // Clear the stored state
280
+ previousHtmlState = null;
105
281
  }
106
- }
107
- })
108
- .catch(error => {
109
- console.error('Error:', error);
110
- });
111
- }
112
282
 
113
- function clearDraft() {
114
- fetch(scriptDeleteUrl, {
115
- method: "DELETE",
116
- headers: {
117
- "Content-Type": "application/json",
118
- },
119
- })
120
- .then(res => res.json())
121
- .then(data => {
122
- if (data.success) {
123
- window.location.reload();
124
- } else {
125
- alert("Failed to clear draft");
283
+ // Check for warnings
284
+ showWarningIfExists(html);
126
285
  }
127
286
  })
128
- .catch(error => console.error("Failed to clear draft", error));
287
+ .catch(error => console.error('Error:', error));
129
288
  }
130
289
 
131
-
132
-
133
-
134
- let previousHtmlState = null; // Store the previous state
135
-
136
290
  function duplicateAction(uuid) {
137
291
  if (!uuid) {
138
292
  console.error('Invalid UUID');
@@ -145,69 +299,86 @@ function duplicateAction(uuid) {
145
299
  'Content-Type': 'application/json'
146
300
  }
147
301
  })
148
-
149
302
  .then(response => response.text())
150
303
  .then(html => {
151
304
  updateActionCanvas(html);
305
+ showWarningIfExists(html);
152
306
  })
153
307
  .catch(error => console.error('Error:', error));
154
308
  }
155
309
 
156
- function editAction(uuid) {
310
+ function deleteAction(uuid) {
157
311
  if (!uuid) {
158
312
  console.error('Invalid UUID');
159
313
  return;
160
314
  }
161
315
 
162
- // Save current state before fetching new content
163
- previousHtmlState = document.getElementById('instrument-panel').innerHTML;
164
-
165
316
  fetch(scriptStepUrl.replace('0', uuid), {
166
- method: 'GET',
317
+ method: 'DELETE',
167
318
  headers: {
168
319
  'Content-Type': 'application/json'
169
320
  }
170
321
  })
171
322
  .then(response => response.text())
172
323
  .then(html => {
173
- document.getElementById('instrument-panel').innerHTML = html;
174
-
175
- // Add click handler for back button
176
- document.getElementById('back').addEventListener('click', function(e) {
177
- e.preventDefault();
178
- if (previousHtmlState) {
179
- document.getElementById('instrument-panel').innerHTML = previousHtmlState;
180
- previousHtmlState = null; // Clear the stored state
181
- }
182
- });
324
+ updateActionCanvas(html);
325
+ showWarningIfExists(html);
183
326
  })
184
327
  .catch(error => console.error('Error:', error));
185
328
  }
186
329
 
330
+ // ============================================================================
331
+ // MODAL MANAGEMENT
332
+ // ============================================================================
187
333
 
334
+ function hideModal() {
335
+ if (document.activeElement) {
336
+ document.activeElement.blur();
337
+ }
188
338
 
189
- function deleteAction(uuid) {
190
- if (!uuid) {
191
- console.error('Invalid UUID');
192
- return;
339
+ $('#dropModal').modal('hide');
340
+
341
+ if (lastFocusedElement) {
342
+ lastFocusedElement.focus();
193
343
  }
344
+ }
194
345
 
195
- fetch(scriptStepUrl.replace('0', uuid), {
196
- method: 'DELETE',
197
- headers: {
198
- 'Content-Type': 'application/json'
199
- }
200
- })
201
- .then(response => response.text())
202
- .then(html => {
203
- // Find the first list element's content and replace it
204
- updateActionCanvas(html);
205
- })
206
- .catch(error => console.error('Error:', error));
346
+ // ============================================================================
347
+ // UTILITY FUNCTIONS
348
+ // ============================================================================
349
+
350
+ function showWarningIfExists(html) {
351
+ const parser = new DOMParser();
352
+ const doc = parser.parseFromString(html, 'text/html');
353
+ const warningDiv = doc.querySelector('#warning');
354
+
355
+ if (warningDiv && warningDiv.textContent.trim()) {
356
+ alert(warningDiv.textContent.trim());
357
+ }
207
358
  }
208
359
 
360
+ // ============================================================================
361
+ // INITIALIZATION
362
+ // ============================================================================
209
363
 
364
+ document.addEventListener("DOMContentLoaded", function() {
365
+ const mode = getMode();
366
+ const batch = getBatch();
210
367
 
368
+ // Set up mode/batch toggle listeners
369
+ document.querySelectorAll(".mode-toggle").forEach(btn => {
370
+ btn.addEventListener("click", () => setMode(btn.dataset.mode));
371
+ });
372
+
373
+ document.querySelectorAll(".batch-toggle").forEach(btn => {
374
+ btn.addEventListener("click", () => setBatch(btn.dataset.batch));
375
+ });
211
376
 
377
+ // Restore UI state (without triggering updates)
378
+ setMode(mode, false);
379
+ setBatch(batch, false);
212
380
 
381
+ // Initialize code overlay
382
+ initializeCodeOverlay();
213
383
 
384
+ });