diff --git a/biosimdb_interface/__init__.py b/biosimdb_interface/__init__.py index 582900e..29919b0 100644 --- a/biosimdb_interface/__init__.py +++ b/biosimdb_interface/__init__.py @@ -86,4 +86,8 @@ def inject_base_url(): app.register_blueprint(login_bp, url_prefix=app.config["APPLICATION_BASE"]) + from .home import home_bp + + app.register_blueprint(home_bp, url_prefix=app.config["APPLICATION_BASE"]) + return app diff --git a/biosimdb_interface/form/__init__.py b/biosimdb_interface/form/__init__.py index 498f6e5..9489b44 100644 --- a/biosimdb_interface/form/__init__.py +++ b/biosimdb_interface/form/__init__.py @@ -3,4 +3,4 @@ form_bp = Blueprint("form", __name__) -from . import extract, home, webform # noqa: E402, F401 +from . import extract, webform # noqa: E402, F401 diff --git a/biosimdb_interface/form/extract.py b/biosimdb_interface/form/extract.py index f93f4eb..1b516f1 100644 --- a/biosimdb_interface/form/extract.py +++ b/biosimdb_interface/form/extract.py @@ -8,12 +8,13 @@ """ import os -import shutil from biosim_extractor.metadata.populatemetadata import MetadataPopulator -from flask import jsonify, request +from flask import jsonify, request, session +from werkzeug.utils import secure_filename from . import form_bp +from .upload import cache_extracted_files, cleanup_tmpdir from .utils import make_upload_tmpdir @@ -51,6 +52,7 @@ def extract_files_validate(top_file, traj_file): @form_bp.route("/extract_metadata", methods=["POST"]) def extract_metadata(): """Extract simulation metadata from uploaded topology and trajectory files. + This is where the tmpdir for the files is created. Expects a multipart POST with: - ``topology``: a single topology file. @@ -65,6 +67,14 @@ def extract_metadata(): - ``{"simulation_metadata": ..., "validation_errors": [...]}`` if schema validation fails. - ``{"error": "..."}`` with status 400 if files are missing, or 500 on unexpected error. """ + # clear the existing tmpdir from previous extraction + tmpdir = session.get("submission_tmpdir") + if tmpdir: + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + tmpdir = make_upload_tmpdir("biosimdb_submission_") + session["submission_tmpdir"] = tmpdir + try: topology = request.files.get("topology") trajectories = request.files.getlist("trajectory[]") @@ -72,38 +82,36 @@ def extract_metadata(): if not topology or not trajectories: return jsonify({"error": "Simulation files are missing."}), 400 - temp_dir = make_upload_tmpdir("biosimdb_extract_") - try: - topo_path = os.path.join(temp_dir, topology.filename) - topology.save(topo_path) - traj_files = [] - - for traj in trajectories: - traj_path = os.path.join(temp_dir, traj.filename) - traj.save(traj_path) - traj_files.append(traj_path) - - result, validation_errors = extract_files_validate(topo_path, traj_files) - - # Keep authoritative extracted payload on the server - # session["extracted_metadata"] = result - - if len(validation_errors) > 0: - return jsonify( - { - "simulation_metadata": result, - "validation_errors": validation_errors, - } - ) - else: - return jsonify( - { - "simulation_metadata": result, - "message": "Metadata extracted successfully.", - } - ) - finally: - shutil.rmtree(temp_dir, ignore_errors=True) + topo_path = os.path.join(tmpdir, secure_filename(topology.filename)) + topology.save(topo_path, buffer_size=16 * 1024 * 1024) # 16 MB chunks + + traj_files = [] + for traj in trajectories: + traj_path = os.path.join(tmpdir, secure_filename(traj.filename)) + traj.save(traj_path, buffer_size=16 * 1024 * 1024) + traj_files.append(traj_path) + + saved_files = {"topology": [topo_path], "trajectory": traj_files} + cache_extracted_files(tmpdir, saved_files) + + result, validation_errors = extract_files_validate(topo_path, traj_files) + + if len(validation_errors) > 0: + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + return jsonify( + { + "simulation_metadata": result, + "validation_errors": validation_errors, + } + ) + else: + return jsonify( + { + "simulation_metadata": result, + "message": "Metadata extracted successfully.", + } + ) except Exception as e: print(f"ERROR: {e}") @@ -111,3 +119,13 @@ def extract_metadata(): traceback.print_exc() return jsonify({"error": str(e)}), 500 + + +@form_bp.route("/clear_extraction", methods=["POST"]) +def clear_extraction(): + """Discard extracted files and reset the pending submission tmpdir.""" + tmpdir = session.get("submission_tmpdir") + cleanup_tmpdir(tmpdir) + for key in ("submission_tmpdir", "topo_path", "traj_files"): + session.pop(key, None) + return jsonify({"success": True}) diff --git a/biosimdb_interface/form/upload.py b/biosimdb_interface/form/upload.py index abb4843..4e73559 100644 --- a/biosimdb_interface/form/upload.py +++ b/biosimdb_interface/form/upload.py @@ -5,22 +5,80 @@ import os import shutil -from biosim_extractor.metadata.filemetadata import files_metadata +from biosim_extractor.metadata.filemetadata import file_metadata, files_metadata from flask import current_app, request, session from werkzeug.utils import secure_filename from .invenio import run_record_upload -from .utils import fill_invenio_metadata, form_to_json, make_upload_tmpdir +from .utils import fill_invenio_metadata, form_to_json PENDING_FORM_FILENAME = "pending_form_data.json" PENDING_UPLOADS_FILENAME = "pending_uploads.json" SIM_METADATA_FILENAME = "simulation_metadata.json" +PENDING_FILE_META_FILENAME = "pending_file_meta.json" +CANCELLED_FLAG_FILENAME = "CANCELLED" -INTERNAL_TMP_FILENAMES = { - PENDING_FORM_FILENAME, - PENDING_UPLOADS_FILENAME, - "metadata.json", -} + +def _pending_file_meta_path(tmpdir): + return os.path.join(tmpdir, PENDING_FILE_META_FILENAME) + + +def _save_pending_file_meta(tmpdir, file_meta): + with open(_pending_file_meta_path(tmpdir), "w") as f: + json.dump(file_meta, f) + + +def _load_pending_file_meta(tmpdir): + path = _pending_file_meta_path(tmpdir) + if not os.path.isfile(path): + return None + with open(path) as f: + return json.load(f) + + +def cache_extracted_files(tmpdir, saved_files): + """Persist saved file paths and computed file metadata for later reuse.""" + file_meta = files_metadata(saved_files) + + with open(_pending_uploads_path(tmpdir), "w") as f: + json.dump(saved_files, f) + _save_pending_file_meta(tmpdir, file_meta) + + session["topo_path"] = ( + saved_files["topology"][0] if saved_files["topology"] else None + ) + session["traj_files"] = saved_files["trajectory"] + return file_meta + + +def verify_cached_file_meta(tmpdir): + """Verify tmpdir files against cached hashes from pending_file_meta.json. + + Returns: + tuple[bool, str | None]: (is_valid, error_message) + """ + cached = _load_pending_file_meta(tmpdir) + if not cached: + return False, "Missing cached file metadata. Please extract metadata again." + + for item in cached: + role = item.get("file_role") + name = item.get("file_name") + expected_hash = item.get("file_hash") + algo = item.get("file_hash_algorithm", "md5") + + if role not in ("topology", "trajectory") or not name or not expected_hash: + continue + + path = os.path.join(tmpdir, name) + if not os.path.isfile(path): + return False, f"Missing file in submission directory: {name}" + + current = file_metadata(path, role=role, hash_algorithm=algo) + if current["file_hash"] != expected_hash: + return False, f"File changed since extraction: {name}" + + return True, None def _pending_form_path(tmpdir): @@ -79,8 +137,6 @@ def _load_pending_upload_paths(tmpdir): with open(path) as f: saved_files = json.load(f) files = [p for p in _flatten_saved_files(saved_files) if os.path.isfile(p)] - - # Optional: keep this if simulation_metadata.json must be included in record files sim_meta_path = os.path.join(tmpdir, SIM_METADATA_FILENAME) if os.path.isfile(sim_meta_path): files.append(sim_meta_path) @@ -88,17 +144,62 @@ def _load_pending_upload_paths(tmpdir): return files +def _paths_are_reusable(tmpdir, topo_path, traj_paths): + """Check whether previously saved simulation paths can be reused safely. + + A path set is reusable when: + - topology and trajectory paths are present, + - each path exists as a file, and + - each path is located under tmpdir. + + Args: + tmpdir (str): Temporary directory expected to contain saved files. + topo_path (str | None): Saved topology file path. + traj_paths (list[str] | None): Saved trajectory file paths. + + Returns: + bool: True if all paths are valid and under tmpdir; otherwise False. + """ + if not topo_path or not traj_paths: + return False + + tmpdir_abs = os.path.abspath(tmpdir) + all_paths = [topo_path, *traj_paths] + + for path in all_paths: + if not path or not os.path.isfile(path): + return False + path_abs = os.path.abspath(path) + if os.path.commonpath([tmpdir_abs, path_abs]) != tmpdir_abs: + return False + + return True + + def _save_request_files(tmpdir): - """Save uploaded request files into a temporary directory grouped by role. + """Save uploaded request files into tmpdir, or reuse existing saved files. + + Reuses session-stored paths when they still point to valid files under + tmpdir. Otherwise saves files from request.files, grouping by role. - Maps the trajectory[] field to trajectory and keeps other field names as roles. + Notes: + The HTML field name trajectory[] is normalized to the "trajectory" role. Args: - tmpdir: Path to the temporary directory where uploaded files are written. + tmpdir (str): Temporary directory where uploaded files are stored. Returns: - Dictionary mapping file roles to lists of saved file paths. + dict[str, list[str]]: Mapping of file role to saved file paths. """ + topo_path = session.get("topo_path") + traj_files = session.get("traj_files") or [] + + if _paths_are_reusable(tmpdir, topo_path, traj_files): + return { + "topology": [topo_path], + "trajectory": traj_files, + } + saved_files = {"topology": [], "trajectory": []} for field in request.files: role = "trajectory" if field == "trajectory[]" else field @@ -107,6 +208,11 @@ def _save_request_files(tmpdir): path = os.path.join(tmpdir, secure_filename(file.filename)) file.save(path) saved_files.setdefault(role, []).append(path) + + if saved_files["topology"]: + session["topo_path"] = saved_files["topology"][0] + session["traj_files"] = saved_files["trajectory"] + return saved_files @@ -126,28 +232,41 @@ def _save_files_and_extract_metadata(tmpdir): return saved_files, file_meta -def extract_uploaded_file_metadata(): - """Extract file metadata from the current request's uploaded files.""" - tmpdir = make_upload_tmpdir("biosimdb_file_metadata_") +def extract_uploaded_file_metadata(tmpdir): + """Extract metadata for uploaded simulation files. + + Reuses cached pending_file_meta.json when present, otherwise saves or + reuses uploaded files and computes metadata. + + Args: + tmpdir (str): Temporary directory where uploaded files are stored. + + Returns: + list[dict]: Extracted metadata records for uploaded files. + """ + cached = _load_pending_file_meta(tmpdir) + if cached is not None: + return cached + try: _, file_meta = _save_files_and_extract_metadata(tmpdir) + _save_pending_file_meta(tmpdir, file_meta) return file_meta finally: for field in request.files: for file in request.files.getlist(field): file.stream.seek(0) - shutil.rmtree(tmpdir, ignore_errors=True) def _data_collections_upload(metadata_path, files_path): """Upload metadata as a draft PSDI data-collections record. Args: - metadata_path: Path to the JSON file containing record metadata. - files_path: List of file paths to upload alongside the record. + metadata_path: Path to the JSON file containing record metadata. + files_path: List of file paths to upload alongside the record. Returns: - tuple: (repository, draft_id) from the Invenio upload response. + tuple: (repository, draft_id) from the Invenio upload response. """ token = session.get("access_token") API_BASE = current_app.config["API_BASE"] @@ -162,6 +281,16 @@ def _data_collections_upload(metadata_path, files_path): return repository, draft_id +def cleanup_tmpdir(tmpdir): + """Remove a temporary submission directory if it exists. + + Args: + tmpdir (str | None): Directory path to delete. + """ + if tmpdir and os.path.isdir(tmpdir): + shutil.rmtree(tmpdir, ignore_errors=True) + + def save_pending_submission(json_form=None): """Persist uploaded files and form payload for post-login submission resume. @@ -178,8 +307,21 @@ def save_pending_submission(json_form=None): Writes JSON artifacts under tmpdir. Sets session["pending_files_dir"]. """ - tmpdir = make_upload_tmpdir("biosimdb_pending_") - saved_files, file_meta = _save_files_and_extract_metadata(tmpdir) + tmpdir = session.get("submission_tmpdir") + # saved_files, file_meta = _save_files_and_extract_metadata(tmpdir) + + topo_path = session.get("topo_path") + traj_files = session.get("traj_files") or [] + saved_files = { + "topology": [topo_path] if topo_path else [], + "trajectory": traj_files, + } + + file_meta = _load_pending_file_meta(tmpdir) + if file_meta is None: + # Fallback only if cache missing + file_meta = files_metadata(saved_files) + _save_pending_file_meta(tmpdir, file_meta) # Persist exact user-uploaded paths for later allowlist upload with open(_pending_uploads_path(tmpdir), "w") as f: @@ -194,8 +336,6 @@ def save_pending_submission(json_form=None): with open(_pending_form_path(tmpdir), "w") as f: json.dump(request.form.to_dict(flat=False), f) - session["pending_files_dir"] = tmpdir - def prepare_for_invenio(form_data, tmpdir): """Create Invenio metadata and upload allowlisted files from tmpdir. @@ -223,3 +363,15 @@ def prepare_for_invenio(form_data, tmpdir): finally: shutil.rmtree(tmpdir, ignore_errors=True) return draft_id + + +def mark_submission_cancelled(tmpdir): + """Signal an in-flight do_submit to stop, without touching its files.""" + if tmpdir and os.path.isdir(tmpdir): + open(os.path.join(tmpdir, CANCELLED_FLAG_FILENAME), "w").close() + + +def is_submission_cancelled(tmpdir): + return bool(tmpdir) and os.path.isfile( + os.path.join(tmpdir, CANCELLED_FLAG_FILENAME) + ) diff --git a/biosimdb_interface/form/validation.py b/biosimdb_interface/form/validation.py index 92ce08d..85b51aa 100644 --- a/biosimdb_interface/form/validation.py +++ b/biosimdb_interface/form/validation.py @@ -1,47 +1,28 @@ #!/usr/bin/env python -import os -import shutil -import tempfile - -from flask import request from MDAnalysis import Universe -from werkzeug.utils import secure_filename -def validate_with_mdanalysis(): - """Validate uploaded topology and trajectory files using MDAnalysis. +def validate_with_mdanalysis(topology_path, trajectory_paths): + """Validate saved simulation files using MDAnalysis. - Saves uploaded files to a temporary directory, attempts to load them - with MDAnalysis, then resets file streams for downstream processing. - Skips validation if no topology file is uploaded. + Args: + topology_path (str): Path to the topology file. + trajectory_paths (list[str]): Paths to one or more trajectory files. Returns: - None if the files are valid or no files were uploaded. - str: Error message if MDAnalysis cannot read the files. + None: If the topology and trajectories are valid. + str: An error message if the files cannot be read by MDAnalysis or + if required paths are missing. """ - topology = request.files.get("topology") - trajectories = [f for f in request.files.getlist("trajectory[]") if f.filename] - if not topology or not topology.filename or len(trajectories) == 0: + if not topology_path or not trajectory_paths: return ( "Please upload a topology and trajectory files before saving or submitting." ) - # return None # no files uploaded, skip validation - tmpdir = tempfile.mkdtemp() + try: - top_path = os.path.join(tmpdir, secure_filename(topology.filename)) - topology.save(top_path) - traj_paths = [] - for traj in trajectories: - p = os.path.join(tmpdir, secure_filename(traj.filename)) - traj.save(p) - traj_paths.append(p) - Universe(top_path, *traj_paths) - return None - except Exception as e: - return str(e) - finally: - topology.stream.seek(0) - for traj in trajectories: - traj.stream.seek(0) - shutil.rmtree(tmpdir, ignore_errors=True) + Universe(topology_path, *trajectory_paths) + except Exception as exc: + return str(exc) + + return None diff --git a/biosimdb_interface/form/webform.py b/biosimdb_interface/form/webform.py index e38c841..64b3760 100644 --- a/biosimdb_interface/form/webform.py +++ b/biosimdb_interface/form/webform.py @@ -22,9 +22,13 @@ from . import form_bp from .upload import ( + cleanup_tmpdir, extract_uploaded_file_metadata, + is_submission_cancelled, + mark_submission_cancelled, prepare_for_invenio, save_pending_submission, + verify_cached_file_meta, ) from .utils import form_to_json, remove_empty_fields from .validation import validate_with_mdanalysis @@ -40,15 +44,32 @@ def webform(): uploaded files plus the validated JSON for deferred Invenio upload, then starts login if needed. """ + clear_client_state = False token = session.get("access_token") + tmpdir = session.get("submission_tmpdir") + + # an abandoned/failed login leaves a pending submit; discard it on return + if request.method == "GET": + if session.pop("force_clear_client_state", False): + clear_client_state = True + elif tmpdir and session.get("post_login_redirect") and not token: + cleanup_tmpdir(tmpdir) + for key in ( + "submission_tmpdir", + "post_login_redirect", + "topo_path", + "traj_files", + ): + session.pop(key, None) + tmpdir = None + clear_client_state = True + if session.pop("last_error", None): + flash( + "Login failed. Please extract and submit your files again.", + "warning", + ) if request.method == "POST": - # are errors being handled correctly? - # check files can be read with mda - mda_error = validate_with_mdanalysis() - if mda_error: - return jsonify({"validation_errors": [mda_error]}) - action = ( "save" if "save" in request.form @@ -57,6 +78,45 @@ def webform(): else None ) + if action == "submit": + if ( + not tmpdir + or not session.get("topo_path") + or not session.get("traj_files") + ): + return jsonify( + { + "validation_errors": [ + "Please extract metadata before submitting." + ] + } + ), 400 + + ok, err = verify_cached_file_meta(tmpdir) + if not ok: + return jsonify({"validation_errors": [err]}), 400 + + if action in ["save", "submit"]: + if ( + not tmpdir + or not session.get("topo_path") + or not session.get("traj_files") + ): + return jsonify( + { + "validation_errors": [ + "Please extract metadata before submitting." + ] + } + ), 400 + + # check files can be read with mda + topo_path = session.get("topo_path") + traj_files = session.get("traj_files") + mda_error = validate_with_mdanalysis(topo_path, traj_files) + + if mda_error: + return jsonify({"validation_errors": [mda_error]}) if action in ["save", "submit"]: # include file info in output, ro-crate? json_form = form_to_json(request.form) @@ -66,10 +126,7 @@ def webform(): json_form = convert_populated_metadata_units(json_form) if action == "save": - json_form["files"] = extract_uploaded_file_metadata() - - # NOTE: note used yet, could be used to validate extracted fields are matching what is returned from json_form, cookie size increases though - # extracted = session.get("extracted_metadata") + json_form["files"] = extract_uploaded_file_metadata(tmpdir) biosimschema_path = os.getenv("BIOSIM_SCHEMA_PATH", "") @@ -106,6 +163,7 @@ def webform(): schema=schema, form_data={}, errors={}, + clear_client_state=clear_client_state, ) @@ -118,7 +176,7 @@ def resume_submit(): """ if not session.get("access_token"): return redirect(url_for("login.login")) - tmpdir = session.get("pending_files_dir") + tmpdir = session.get("submission_tmpdir") pending_form_path = ( os.path.join(tmpdir, "pending_form_data.json") if tmpdir else None ) @@ -136,12 +194,17 @@ def do_submit(): Clears pending session data after upload and renders the success page with the record URL. """ - tmpdir = session.pop("pending_files_dir", None) + tmpdir = session.get("submission_tmpdir") if not tmpdir: flash("No pending submission found. Please submit again.", "warning") return redirect(url_for("form.webform")) + if is_submission_cancelled(tmpdir): + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + return redirect(url_for("form.webform")) + pending_form_path = os.path.join(tmpdir, "pending_form_data.json") if not os.path.isfile(pending_form_path): flash("No pending submission found. Please submit again.", "warning") @@ -157,7 +220,20 @@ def do_submit(): try: token = session.get("access_token") invite_user("biosimdb", token) + + if is_submission_cancelled(tmpdir): + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + return redirect(url_for("form.webform")) + draft_id = prepare_for_invenio(flat_form, tmpdir) + + if not draft_id: + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + flash("Upload failed. Please try again.", "danger") + return redirect(url_for("form.webform")) + except requests.HTTPError as exc: status = exc.response.status_code if exc.response is not None else None @@ -170,19 +246,56 @@ def do_submit(): "warning", ) return redirect(url_for("login.login")) - else: - session.pop("access_token", None) # force fresh login - flash("Upload failed unexpectedly. Please try again.", "danger") - # keep pending_form_data and pending_files_dir for retry + session.pop("access_token", None) # force fresh login + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) + flash("Upload failed unexpectedly. Please try again.", "danger") return redirect(url_for("form.webform")) - # success: now clear pending state etc. - session.pop("pending_files_dir", None) + # success: now clear submission data and logout user. + cleanup_tmpdir(tmpdir) + session.pop("submission_tmpdir", None) session.pop("access_token", None) session.pop("user_email", None) session.pop("post_login_redirect", None) BASE_URL = current_app.config["BASE_URL"] record_url = f"{BASE_URL}/uploads/{draft_id}" + session["submitted_record_url"] = record_url + return redirect(url_for("form.submit_success")) + # return render_template("form/submit_success.html", record_url=record_url) + + +@form_bp.route("/cancel_submit", methods=["POST"]) +def cancel_submit(): + """Signal an in-progress submission to stop and reset client-facing session state. + + Does not delete the tmpdir directly, since the in-flight do_submit request + still owns those files; do_submit checks the cancellation flag itself and + performs its own cleanup once it is safe to do so. + """ + tmpdir = session.get("submission_tmpdir") + mark_submission_cancelled(tmpdir) + + for key in ( + "submission_tmpdir", + "post_login_redirect", + "topo_path", + "traj_files", + "access_token", + "user_email", + ): + session.pop(key, None) + session["force_clear_client_state"] = True + return jsonify({"success": True}) + + +@form_bp.route("/submit_success") +def submit_success(): + """Render the successful Invenio submission page.""" + record_url = session.get("submitted_record_url") + if not record_url: + flash("No completed submission found.", "warning") + return redirect(url_for("form.webform")) return render_template("form/submit_success.html", record_url=record_url) diff --git a/biosimdb_interface/home/__init__.py b/biosimdb_interface/home/__init__.py new file mode 100644 index 0000000..16cd87e --- /dev/null +++ b/biosimdb_interface/home/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +from flask import Blueprint + +home_bp = Blueprint("home", __name__) + +from . import home # noqa: E402, F401 diff --git a/biosimdb_interface/form/home.py b/biosimdb_interface/home/home.py similarity index 92% rename from biosimdb_interface/form/home.py rename to biosimdb_interface/home/home.py index f7f4e4a..e83256c 100644 --- a/biosimdb_interface/form/home.py +++ b/biosimdb_interface/home/home.py @@ -9,14 +9,14 @@ url_for, ) -from . import form_bp +from ..home import home_bp -@form_bp.route("/", methods=["GET"]) +@home_bp.route("/", methods=["GET"]) def home(): """Landing page with BioSimDB overview and quick links.""" return render_template( - "main/home.html", + "home/home.html", links={ "deposit": url_for("form.webform"), "biosimdb": f"{current_app.config.get('BASE_URL', '').rstrip('/')}/communities/biosimdb", diff --git a/biosimdb_interface/static/js/form/metadata.js b/biosimdb_interface/static/js/form/metadata.js index f12967d..bba7808 100644 --- a/biosimdb_interface/static/js/form/metadata.js +++ b/biosimdb_interface/static/js/form/metadata.js @@ -3,17 +3,7 @@ document.addEventListener('click', function(e) { if (e.target.classList.contains('extract-metadata-btn')) { // if (e.target.disabled) return; // Prevent double clicks - e.target.disabled = true; - - // Add a wait spinner and grey out background page when data extraction is happening - const loadingOverlay = document.createElement('div'); - loadingOverlay.id = 'loading-overlay'; - loadingOverlay.style.cssText = 'position:fixed; inset:0; z-index:2000; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,0.5);'; - loadingOverlay.innerHTML = '
Submitting to BioSimDB, please wait...
+BioSimDB record created successfully! Please complete your submission there.
-Redirecting to your record in 10 seconds...