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 = '
Loading...
'; - document.body.appendChild(loadingOverlay); - - - e.target.innerHTML = 'Extracting...'; + showLoadingOverlay(e.target, 'Extracting...'); setFieldsDisabled(true); const formData = new FormData(); @@ -42,26 +32,23 @@ document.addEventListener('click', function(e) { saveFormState(); setFieldsDisabled(false); if (data.message) { - showAlert(data.message, 'success', 4000); + showAlert(data.message, 'success', 5000); } if (data.validation_errors && data.validation_errors.length > 0) { const [heading, ...errors] = data.validation_errors; const list = errors.map(e => `
  • ${e}
  • `).join(''); - showAlert(`${heading}`, 'warning', 4000); + showAlert(`${heading}`, 'warning', 5000); } } }) - .finally(() => { - e.target.disabled = false; - e.target.textContent = 'Extract Metadata'; - document.getElementById('loading-overlay')?.remove(); - }); + .finally(() => hideLoadingOverlay(e.target)); } if (e.target.matches('input[name="submit"]')) { e.preventDefault(); const form = document.getElementById('simulationForm'); if (!requireExtraction(form)) return; + showLoadingOverlay(e.target, 'Submitting...'); validateAndSubmit(form, () => { sessionStorage.removeItem('formState'); sessionStorage.removeItem('extractedMetadata'); @@ -69,6 +56,8 @@ document.addEventListener('click', function(e) { hidden.type = 'hidden'; hidden.name = 'submit'; hidden.value = '1'; form.appendChild(hidden); HTMLFormElement.prototype.submit.call(form); + }).then(success => { + if (!success) hideLoadingOverlay(e.target); }); } @@ -76,13 +65,14 @@ document.addEventListener('click', function(e) { e.preventDefault(); const form = document.getElementById('simulationForm'); if (!requireExtraction(form)) return; + showLoadingOverlay(e.target, 'Saving...'); validateAndSubmit(form, (data) => { const blob = new Blob([JSON.stringify(data.data, null, 2)], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'simulation_metadata.json'; a.click(); URL.revokeObjectURL(url); - }); + }).finally(() => hideLoadingOverlay(e.target)); } if (e.target.classList.contains('add-instance')) { @@ -127,34 +117,7 @@ document.addEventListener('click', function(e) { const form = document.getElementById('simulationForm'); // Clear all non-button inputs/selects/textareas - form.querySelectorAll('input, select, textarea').forEach(el => { - const type = (el.type || '').toLowerCase(); - - if (type === 'submit' || type === 'button' || type === 'hidden') return; - - if (type === 'checkbox' || type === 'radio') { - el.checked = false; - } else if (type === 'file') { - el.value = ''; - } else if (el.tagName === 'SELECT') { - el.selectedIndex = 0; - } else { - el.value = ''; - } - }); - - // Keep only first molecule_ID instance per container - form.querySelectorAll('.multiple-field-container').forEach(container => { - const instances = container.querySelectorAll('.field-instance'); - instances.forEach((instance, idx) => { - if (idx > 0) instance.remove(); - }); - renumberInstances(container); - }); - - setFieldsDisabled(true); - sessionStorage.removeItem('extractedMetadata'); - sessionStorage.removeItem('formState'); + lockForm(); } }); @@ -355,6 +318,12 @@ function restoreFormState() { // On page load: restore extracted metadata and form state from sessionStorage, // or disable fields if the form is empty. document.addEventListener('DOMContentLoaded', () => { + + if (window.CLEAR_CLIENT_STATE) { + lockForm(); + return; + } + const saved = sessionStorage.getItem('extractedMetadata'); if (saved) { populateFields(JSON.parse(saved)); @@ -362,14 +331,15 @@ document.addEventListener('DOMContentLoaded', () => { setFieldsDisabled(false); return; } - const hasValues = Array.from( - document.querySelectorAll('#simulationForm input, #simulationForm select, #simulationForm textarea') - ).some(el => { - const type = (el.type || '').toLowerCase(); - if (type === 'file' || type === 'button' || type === 'submit') return false; - return type === 'checkbox' ? el.checked : el.value.trim() !== ''; - }); - if (!hasValues) setFieldsDisabled(true); + lockForm(); +}); + +window.addEventListener('pageshow', (event) => { + if (event.persisted) { + if (window.CLEAR_CLIENT_STATE || !sessionStorage.getItem('extractedMetadata')) { + lockForm(); + } + } }); // Persist form state on any user input so it survives page refreshes. @@ -401,6 +371,23 @@ function showAlert(html, type = 'warning', timeout = 50000) { } } +/** + * Fade out and remove an existing Bootstrap alert after a delay. + * + * @param {HTMLElement} alertEl - Rendered Bootstrap alert element. + * @param {number} timeout - Delay in milliseconds. + */ +function dismissAlertAfter(alertEl, timeout = 5000) { + setTimeout(() => { + alertEl.classList.remove('show'); + setTimeout(() => alertEl.remove(), 150); + }, timeout); +} + +document.querySelectorAll('.alert').forEach((alertEl) => { + dismissAlertAfter(alertEl, 5000); +}); + /** * Guards save/submit actions by checking that metadata has been extracted. * Uses native browser validation UI (`reportValidity`) to surface the error on the topology input. @@ -427,16 +414,83 @@ function requireExtraction(form) { function validateAndSubmit(form, onSuccess) { const formData = new FormData(form); formData.append('save', '1'); - fetch(window.APPLICATION_BASE + '/webform', { method: 'POST', body: formData }) + return fetch(window.APPLICATION_BASE + '/webform', { method: 'POST', body: formData }) .then(r => r.json()) .then(data => { if (data.validation_errors && data.validation_errors.length > 0) { const [heading, ...errors] = data.validation_errors; const list = errors.map(err => `
  • ${err}
  • `).join(''); - showAlert(`${heading}`, 'warning', 4000); - } else if (data.success) { + showAlert(`${heading}`, 'warning', 5000); + return false; + } + if (data.success) { onSuccess(data); + return true; } + return false; }) - .catch(err => showAlert(`Error: ${err.message}`, 'danger', 5000)); + .catch(err => { + showAlert(`Error: ${err.message}`, 'danger', 5000); + return false; + }); +} + +/** + * Resets the form to its initial locked state: clears all field values, + * removes cached extraction state, and disables editable fields. + */ +function lockForm() { + document.querySelectorAll('#simulationForm input, #simulationForm select, #simulationForm textarea').forEach(el => { + const type = (el.type || '').toLowerCase(); + if (type === 'submit' || type === 'button' || type === 'hidden') return; + + if (type === 'checkbox' || type === 'radio') { + el.checked = false; + } else if (el.tagName === 'SELECT') { + el.selectedIndex = 0; + } else { + el.value = ''; + } + }); + + document.querySelectorAll('#simulationForm .multiple-field-container').forEach(container => { + container.querySelectorAll('.field-instance').forEach((instance, idx) => { + if (idx > 0) instance.remove(); + }); + renumberInstances(container); + }); + + sessionStorage.removeItem('extractedMetadata'); + sessionStorage.removeItem('formState'); + setFieldsDisabled(true); + + fetch(window.APPLICATION_BASE + '/clear_extraction', { method: 'POST' }); +} + +/** + * Shows a full-page wait overlay and puts a button into a disabled "working" state. + * @param {HTMLElement} button - The button that triggered the action. + * @param {string} loadingText - Text to show on the button while working. + */ +function showLoadingOverlay(button, loadingText) { + button.dataset.originalText = button.textContent; + button.disabled = true; + button.innerHTML = `${loadingText}`; + + const overlay = document.createElement('div'); + overlay.id = 'loading-overlay'; + overlay.style.cssText = 'position:fixed; inset:0; z-index:2000; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,0.5);'; + overlay.innerHTML = '
    Loading...
    '; + document.body.appendChild(overlay); +} + +/** + * Restores a button's original label and removes the wait overlay. + * @param {HTMLElement} button - The button to restore. + */ +function hideLoadingOverlay(button) { + button.disabled = false; + button.textContent = button.dataset.originalText ?? button.textContent; + delete button.dataset.originalText; + document.getElementById('loading-overlay')?.remove(); } diff --git a/biosimdb_interface/templates/form/loading.html b/biosimdb_interface/templates/form/loading.html index e7ef060..480e0b5 100644 --- a/biosimdb_interface/templates/form/loading.html +++ b/biosimdb_interface/templates/form/loading.html @@ -3,22 +3,43 @@ Intermediate page shown while a BioSimDB submission is in progress. Behaviour: - - Displays a spinner while an invisible form auto-submits via POST to form.do_submit. - - The hidden form carries the session cookie so the server can retrieve the pending submission. - - On completion, the server redirects to submit_success.html. + - Submits to form.do_submit via fetch so it can be cancelled client-side. + - On completion, navigates to the final response URL (success or webform). + - Cancel aborts the fetch and resets session state via form.cancel_submit. #} {% extends "main/base.html" %} {% block content %}

    Submitting to BioSimDB, please wait...

    +
    - {% endblock %} {% block scripts %} {{ super() }} {% endblock %} diff --git a/biosimdb_interface/templates/form/submit_success.html b/biosimdb_interface/templates/form/submit_success.html index 1a4a7c6..4b4b7ee 100644 --- a/biosimdb_interface/templates/form/submit_success.html +++ b/biosimdb_interface/templates/form/submit_success.html @@ -15,10 +15,9 @@ {% block content %}

    BioSimDB record created successfully! Please complete your submission there.

    -

    Redirecting to your record in 10 seconds...

    - View Record - Return to Webform + View Record + Return to Webform
    {% endblock %} @@ -30,20 +29,5 @@ sessionStorage.removeItem('formState'); sessionStorage.removeItem('extractedMetadata'); }); - - let seconds = 10; - const countdown = document.getElementById('countdown'); - const interval = setInterval(() => { - seconds--; - countdown.textContent = seconds; - if (seconds <= 0) { - clearInterval(interval); - const win = window.open('{{ record_url }}', '_blank', 'noopener,noreferrer'); - if (!win) { - // Fallback if popup was blocked - window.location = '{{ record_url }}'; - } - } - }, 1000); {% endblock %} diff --git a/biosimdb_interface/templates/form/webform.html b/biosimdb_interface/templates/form/webform.html index d4e6c86..bfed4bc 100644 --- a/biosimdb_interface/templates/form/webform.html +++ b/biosimdb_interface/templates/form/webform.html @@ -98,7 +98,7 @@
    @@ -350,6 +350,10 @@

    {% block scripts %} {{ super() }} + +