Incremental update

Update Push IG  log file with additional detail.
enahanced validation report structure
fixed the DANM expand collapse chevron, every UI change breaks that functionality
fixed the copy raw structure, pretty json, and raw json copy buttons so they work independant.
This commit is contained in:
Joshua Hare 2025-04-14 21:38:36 +10:00
parent df3ffadbbb
commit f38b6aedba
15 changed files with 820 additions and 505 deletions

128
app.py
View File

@ -614,7 +614,9 @@ def api_push_ig():
package_name = data.get('package_name') package_name = data.get('package_name')
version = data.get('version') version = data.get('version')
fhir_server_url = data.get('fhir_server_url') fhir_server_url = data.get('fhir_server_url')
include_dependencies = data.get('include_dependencies', True) include_dependencies = data.get('include_dependencies', True) # Default to True if not provided
# --- Input Validation ---
if not all([package_name, version, fhir_server_url]): if not all([package_name, version, fhir_server_url]):
return jsonify({"status": "error", "message": "Missing package_name, version, or fhir_server_url"}), 400 return jsonify({"status": "error", "message": "Missing package_name, version, or fhir_server_url"}), 400
if not (isinstance(fhir_server_url, str) and fhir_server_url.startswith(('http://', 'https://'))): if not (isinstance(fhir_server_url, str) and fhir_server_url.startswith(('http://', 'https://'))):
@ -623,26 +625,35 @@ def api_push_ig():
re.match(r'^[a-zA-Z0-9\-\.]+$', package_name) and re.match(r'^[a-zA-Z0-9\-\.]+$', package_name) and
re.match(r'^[a-zA-Z0-9\.\-\+]+$', version)): re.match(r'^[a-zA-Z0-9\.\-\+]+$', version)):
return jsonify({"status": "error", "message": "Invalid characters in package name or version"}), 400 return jsonify({"status": "error", "message": "Invalid characters in package name or version"}), 400
# --- File Path Setup ---
packages_dir = current_app.config.get('FHIR_PACKAGES_DIR') packages_dir = current_app.config.get('FHIR_PACKAGES_DIR')
if not packages_dir: if not packages_dir:
logger.error("[API Push] FHIR_PACKAGES_DIR not configured.") logger.error("[API Push] FHIR_PACKAGES_DIR not configured.")
return jsonify({"status": "error", "message": "Server configuration error: Package directory not set."}), 500 return jsonify({"status": "error", "message": "Server configuration error: Package directory not set."}), 500
tgz_filename = services.construct_tgz_filename(package_name, version) tgz_filename = services.construct_tgz_filename(package_name, version)
tgz_path = os.path.join(packages_dir, tgz_filename) tgz_path = os.path.join(packages_dir, tgz_filename)
if not os.path.exists(tgz_path): if not os.path.exists(tgz_path):
logger.error(f"[API Push] Main package not found: {tgz_path}") logger.error(f"[API Push] Main package not found: {tgz_path}")
return jsonify({"status": "error", "message": f"Package not found locally: {package_name}#{version}"}), 404 return jsonify({"status": "error", "message": f"Package not found locally: {package_name}#{version}"}), 404
# --- Streaming Response ---
def generate_stream(): def generate_stream():
pushed_packages_info = [] pushed_packages_info = []
success_count = 0 success_count = 0
failure_count = 0 failure_count = 0
validation_failure_count = 0 validation_failure_count = 0 # Note: This count is not currently incremented anywhere.
total_resources_attempted = 0 total_resources_attempted = 0
processed_resources = set() processed_resources = set()
failed_uploads_details = [] # <<<<< Initialize list for failure details
try: try:
yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version} to {fhir_server_url}"}) + "\n" yield json.dumps({"type": "start", "message": f"Starting push for {package_name}#{version} to {fhir_server_url}"}) + "\n"
packages_to_push = [(package_name, version, tgz_path)] packages_to_push = [(package_name, version, tgz_path)]
dependencies_to_include = [] dependencies_to_include = []
# --- Dependency Handling ---
if include_dependencies: if include_dependencies:
yield json.dumps({"type": "progress", "message": "Checking dependencies..."}) + "\n" yield json.dumps({"type": "progress", "message": "Checking dependencies..."}) + "\n"
metadata = services.get_package_metadata(package_name, version) metadata = services.get_package_metadata(package_name, version)
@ -657,14 +668,18 @@ def api_push_ig():
dep_tgz_filename = services.construct_tgz_filename(dep_name, dep_version) dep_tgz_filename = services.construct_tgz_filename(dep_name, dep_version)
dep_tgz_path = os.path.join(packages_dir, dep_tgz_filename) dep_tgz_path = os.path.join(packages_dir, dep_tgz_filename)
if os.path.exists(dep_tgz_path): if os.path.exists(dep_tgz_path):
packages_to_push.append((dep_name, dep_version, dep_tgz_path)) # Add dependency package to the list of packages to process
yield json.dumps({"type": "progress", "message": f"Queued dependency: {dep_name}#{dep_version}"}) + "\n" if (dep_name, dep_version, dep_tgz_path) not in packages_to_push: # Avoid adding duplicates if listed multiple times
packages_to_push.append((dep_name, dep_version, dep_tgz_path))
yield json.dumps({"type": "progress", "message": f"Queued dependency: {dep_name}#{dep_version}"}) + "\n"
else: else:
yield json.dumps({"type": "warning", "message": f"Dependency package file not found, skipping: {dep_name}#{dep_version} ({dep_tgz_filename})"}) + "\n" yield json.dumps({"type": "warning", "message": f"Dependency package file not found, skipping: {dep_name}#{dep_version} ({dep_tgz_filename})"}) + "\n"
else: else:
yield json.dumps({"type": "info", "message": "No dependency metadata found or no dependencies listed."}) + "\n" yield json.dumps({"type": "info", "message": "No dependency metadata found or no dependencies listed."}) + "\n"
# --- Resource Extraction ---
resources_to_upload = [] resources_to_upload = []
seen_resource_files = set() seen_resource_files = set() # Track processed filenames to avoid duplicates across packages
for pkg_name, pkg_version, pkg_path in packages_to_push: for pkg_name, pkg_version, pkg_path in packages_to_push:
yield json.dumps({"type": "progress", "message": f"Extracting resources from {pkg_name}#{pkg_version}..."}) + "\n" yield json.dumps({"type": "progress", "message": f"Extracting resources from {pkg_name}#{pkg_version}..."}) + "\n"
try: try:
@ -673,13 +688,16 @@ def api_push_ig():
if (member.isfile() and if (member.isfile() and
member.name.startswith('package/') and member.name.startswith('package/') and
member.name.lower().endswith('.json') and member.name.lower().endswith('.json') and
os.path.basename(member.name).lower() not in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']): os.path.basename(member.name).lower() not in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']): # Skip common metadata files
if member.name in seen_resource_files:
if member.name in seen_resource_files: # Skip if already seen from another package (e.g., core resource in multiple IGs)
continue continue
seen_resource_files.add(member.name) seen_resource_files.add(member.name)
try: try:
with tar.extractfile(member) as f: with tar.extractfile(member) as f:
resource_data = json.load(f) resource_data = json.load(f) # Read and parse JSON content
# Basic check for a valid FHIR resource structure
if isinstance(resource_data, dict) and 'resourceType' in resource_data and 'id' in resource_data: if isinstance(resource_data, dict) and 'resourceType' in resource_data and 'id' in resource_data:
resources_to_upload.append({ resources_to_upload.append({
"data": resource_data, "data": resource_data,
@ -690,34 +708,49 @@ def api_push_ig():
yield json.dumps({"type": "warning", "message": f"Skipping invalid/incomplete resource in {member.name} from {pkg_name}#{pkg_version}"}) + "\n" yield json.dumps({"type": "warning", "message": f"Skipping invalid/incomplete resource in {member.name} from {pkg_name}#{pkg_version}"}) + "\n"
except (json.JSONDecodeError, UnicodeDecodeError) as json_e: except (json.JSONDecodeError, UnicodeDecodeError) as json_e:
yield json.dumps({"type": "warning", "message": f"Skipping non-JSON or corrupt file {member.name} from {pkg_name}#{pkg_version}: {json_e}"}) + "\n" yield json.dumps({"type": "warning", "message": f"Skipping non-JSON or corrupt file {member.name} from {pkg_name}#{pkg_version}: {json_e}"}) + "\n"
except Exception as extract_e: except Exception as extract_e: # Catch potential errors during file extraction within the tar
yield json.dumps({"type": "warning", "message": f"Error extracting file {member.name} from {pkg_name}#{pkg_version}: {extract_e}"}) + "\n" yield json.dumps({"type": "warning", "message": f"Error extracting file {member.name} from {pkg_name}#{pkg_version}: {extract_e}"}) + "\n"
except (tarfile.TarError, FileNotFoundError) as tar_e: except (tarfile.TarError, FileNotFoundError) as tar_e:
yield json.dumps({"type": "error", "message": f"Error reading package {pkg_name}#{pkg_version}: {tar_e}. Skipping its resources."}) + "\n" # Error reading the package itself
failure_count += 1 error_msg = f"Error reading package {pkg_name}#{pkg_version}: {tar_e}. Skipping its resources."
continue yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 # Increment general failure count
failed_uploads_details.append({'resource': f"Package: {pkg_name}#{pkg_version}", 'error': f"TarError/FileNotFound: {tar_e}"}) # <<<<< ADDED package level error
continue # Skip to the next package if one fails to open
total_resources_attempted = len(resources_to_upload) total_resources_attempted = len(resources_to_upload)
yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload."}) + "\n" yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload across all packages."}) + "\n"
session = requests.Session()
# --- Resource Upload Loop ---
session = requests.Session() # Use a session for potential connection reuse
headers = {'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'} headers = {'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'}
for i, resource_info in enumerate(resources_to_upload, 1): for i, resource_info in enumerate(resources_to_upload, 1):
resource = resource_info["data"] resource = resource_info["data"]
source_pkg = resource_info["source_package"] source_pkg = resource_info["source_package"]
source_file = resource_info["source_filename"] # source_file = resource_info["source_filename"] # Available if needed
resource_type = resource.get('resourceType') resource_type = resource.get('resourceType')
resource_id = resource.get('id') resource_id = resource.get('id')
resource_log_id = f"{resource_type}/{resource_id}" resource_log_id = f"{resource_type}/{resource_id}"
# Skip if already processed (e.g., if same resource ID appeared in multiple packages)
if resource_log_id in processed_resources: if resource_log_id in processed_resources:
yield json.dumps({"type": "info", "message": f"Skipping duplicate resource: {resource_log_id} (already uploaded or queued)"}) + "\n" yield json.dumps({"type": "info", "message": f"Skipping duplicate resource ID: {resource_log_id} (already attempted/uploaded)"}) + "\n"
continue continue
processed_resources.add(resource_log_id) processed_resources.add(resource_log_id)
resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}"
resource_url = f"{fhir_server_url.rstrip('/')}/{resource_type}/{resource_id}" # Construct PUT URL
yield json.dumps({"type": "progress", "message": f"Uploading {resource_log_id} ({i}/{total_resources_attempted}) from {source_pkg}..."}) + "\n" yield json.dumps({"type": "progress", "message": f"Uploading {resource_log_id} ({i}/{total_resources_attempted}) from {source_pkg}..."}) + "\n"
try: try:
# Attempt to PUT the resource
response = session.put(resource_url, json=resource, headers=headers, timeout=30) response = session.put(resource_url, json=resource, headers=headers, timeout=30)
response.raise_for_status() response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
yield json.dumps({"type": "success", "message": f"Uploaded {resource_log_id} successfully (Status: {response.status_code})"}) + "\n" yield json.dumps({"type": "success", "message": f"Uploaded {resource_log_id} successfully (Status: {response.status_code})"}) + "\n"
success_count += 1 success_count += 1
# Track which packages contributed successful uploads
if source_pkg not in [p["id"] for p in pushed_packages_info]: if source_pkg not in [p["id"] for p in pushed_packages_info]:
pushed_packages_info.append({"id": source_pkg, "resource_count": 1}) pushed_packages_info.append({"id": source_pkg, "resource_count": 1})
else: else:
@ -725,68 +758,88 @@ def api_push_ig():
if p["id"] == source_pkg: if p["id"] == source_pkg:
p["resource_count"] += 1 p["resource_count"] += 1
break break
# --- Error Handling for Upload ---
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
error_msg = f"Timeout uploading {resource_log_id} to {resource_url}" error_msg = f"Timeout uploading {resource_log_id} to {resource_url}"
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
error_msg = f"Connection error uploading {resource_log_id}: {e}" error_msg = f"Connection error uploading {resource_log_id}: {e}"
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
outcome_text = "" outcome_text = ""
# Try to parse OperationOutcome from response
try: try:
outcome = e.response.json() outcome = e.response.json()
if outcome and outcome.get('resourceType') == 'OperationOutcome' and outcome.get('issue'): if outcome and outcome.get('resourceType') == 'OperationOutcome' and outcome.get('issue'):
outcome_text = "; ".join([f"{issue.get('severity')}: {issue.get('diagnostics')}" for issue in outcome['issue']]) outcome_text = "; ".join([f"{issue.get('severity', 'info')}: {issue.get('diagnostics') or issue.get('details', {}).get('text', 'No details')}" for issue in outcome['issue']])
except: except ValueError: # Handle cases where response is not JSON
outcome_text = e.response.text[:200] outcome_text = e.response.text[:200] # Fallback to raw text
error_msg = f"Failed to upload {resource_log_id} (Status: {e.response.status_code}): {outcome_text or str(e)}" error_msg = f"Failed to upload {resource_log_id} (Status: {e.response.status_code}): {outcome_text or str(e)}"
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
except requests.exceptions.RequestException as e: failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
error_msg = f"Failed to upload {resource_log_id}: {str(e)}" except requests.exceptions.RequestException as e: # Catch other potential request errors
error_msg = f"Request error uploading {resource_log_id}: {str(e)}"
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
except Exception as e: failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
except Exception as e: # Catch unexpected errors during the PUT request
error_msg = f"Unexpected error uploading {resource_log_id}: {str(e)}" error_msg = f"Unexpected error uploading {resource_log_id}: {str(e)}"
yield json.dumps({"type": "error", "message": error_msg}) + "\n" yield json.dumps({"type": "error", "message": error_msg}) + "\n"
failure_count += 1 failure_count += 1
logger.error(f"[API Push] Unexpected upload error: {e}", exc_info=True) failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
logger.error(f"[API Push] Unexpected upload error for {resource_log_id}: {e}", exc_info=True)
# --- Final Summary ---
# Determine overall status based on counts
final_status = "success" if failure_count == 0 and total_resources_attempted > 0 else \ final_status = "success" if failure_count == 0 and total_resources_attempted > 0 else \
"partial" if success_count > 0 else \ "partial" if success_count > 0 else \
"failure" "failure"
summary_message = f"Push finished: {success_count} succeeded, {failure_count} failed out of {total_resources_attempted} resources attempted." summary_message = f"Push finished: {success_count} succeeded, {failure_count} failed out of {total_resources_attempted} resources attempted."
if validation_failure_count > 0: if validation_failure_count > 0: # This count isn't used currently, but keeping structure
summary_message += f" ({validation_failure_count} failed validation)." summary_message += f" ({validation_failure_count} failed validation)."
# Create final summary payload
summary = { summary = {
"status": final_status, "status": final_status,
"message": summary_message, "message": summary_message,
"target_server": fhir_server_url, "target_server": fhir_server_url,
"package_name": package_name, "package_name": package_name, # Initial requested package
"version": version, "version": version, # Initial requested version
"included_dependencies": include_dependencies, "included_dependencies": include_dependencies,
"resources_attempted": total_resources_attempted, "resources_attempted": total_resources_attempted,
"success_count": success_count, "success_count": success_count,
"failure_count": failure_count, "failure_count": failure_count,
"validation_failure_count": validation_failure_count, "validation_failure_count": validation_failure_count,
"pushed_packages_summary": pushed_packages_info "failed_details": failed_uploads_details, # <<<<< ADDED Failure details list
"pushed_packages_summary": pushed_packages_info # Summary of packages contributing uploads
} }
yield json.dumps({"type": "complete", "data": summary}) + "\n" yield json.dumps({"type": "complete", "data": summary}) + "\n"
logger.info(f"[API Push] Completed for {package_name}#{version}. Status: {final_status}. {summary_message}") logger.info(f"[API Push] Completed for {package_name}#{version}. Status: {final_status}. {summary_message}")
except Exception as e:
logger.error(f"[API Push] Critical error during push stream generation: {str(e)}", exc_info=True) except Exception as e: # Catch critical errors during the entire stream generation
logger.error(f"[API Push] Critical error during push stream generation for {package_name}#{version}: {str(e)}", exc_info=True)
# Attempt to yield a final error message to the stream
error_response = { error_response = {
"status": "error", "status": "error",
"message": f"Server error during push operation: {str(e)}" "message": f"Server error during push operation: {str(e)}"
# Optionally add failed_details collected so far if needed
# "failed_details": failed_uploads_details
} }
try: try:
# Send error info both as a stream message and in the complete data
yield json.dumps({"type": "error", "message": error_response["message"]}) + "\n" yield json.dumps({"type": "error", "message": error_response["message"]}) + "\n"
yield json.dumps({"type": "complete", "data": error_response}) + "\n" yield json.dumps({"type": "complete", "data": error_response}) + "\n"
except GeneratorExit: except GeneratorExit: # If the client disconnects
logger.warning("[API Push] Stream closed before final error could be sent.") logger.warning("[API Push] Stream closed before final error could be sent.")
except Exception as yield_e: except Exception as yield_e: # Error during yielding the error itself
logger.error(f"[API Push] Error yielding final error message: {yield_e}") logger.error(f"[API Push] Error yielding final error message: {yield_e}")
# Return the streaming response
return Response(generate_stream(), mimetype='application/x-ndjson') return Response(generate_stream(), mimetype='application/x-ndjson')
@app.route('/validate-sample', methods=['GET']) @app.route('/validate-sample', methods=['GET'])
@ -818,6 +871,13 @@ def validate_sample():
now=datetime.datetime.now() now=datetime.datetime.now()
) )
# Exempt specific API views defined directly on 'app'
csrf.exempt(api_import_ig) # Add this line
csrf.exempt(api_push_ig) # Add this line
# Exempt the entire API blueprint (for routes defined IN services.py, like /api/validate-sample)
csrf.exempt(services_bp) # Keep this line for routes defined in the blueprint
def create_db(): def create_db():
logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}") logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
try: try:

Binary file not shown.

View File

@ -17,5 +17,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.760501+00:00"
} }

View File

@ -29,5 +29,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:09.766234+00:00"
} }

View File

@ -4,5 +4,6 @@
"dependency_mode": "recursive", "dependency_mode": "recursive",
"imported_dependencies": [], "imported_dependencies": [],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.077657+00:00"
} }

View File

@ -9,5 +9,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.693803+00:00"
} }

View File

@ -17,5 +17,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.802489+00:00"
} }

View File

@ -9,5 +9,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:12.239784+00:00"
} }

View File

@ -13,5 +13,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.776036+00:00"
} }

View File

@ -9,5 +9,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:12.227176+00:00"
} }

View File

@ -9,5 +9,6 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [] "imposed_profiles": [],
"timestamp": "2025-04-14T10:53:11.555556+00:00"
} }

View File

@ -143,7 +143,7 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None):
return None, None return None, None
try: try:
with tarfile.open(tgz_path, "r:gz") as tar: with tarfile.open(tgz_path, "r:gz") as tar:
logger.debug(f"Searching for SD matching '{resource_identifier}' in {os.path.basename(tgz_path)}") logger.debug(f"Searching for SD matching '{resource_identifier}' with profile '{profile_url}' in {os.path.basename(tgz_path)}")
for member in tar: for member in tar:
if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')): if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
continue continue
@ -161,23 +161,28 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None):
sd_name = data.get('name') sd_name = data.get('name')
sd_type = data.get('type') sd_type = data.get('type')
sd_url = data.get('url') sd_url = data.get('url')
# Log SD details for debugging
logger.debug(f"Found SD: id={sd_id}, name={sd_name}, type={sd_type}, url={sd_url}, path={member.name}")
# Prioritize match with profile_url if provided # Prioritize match with profile_url if provided
if profile_url and sd_url == profile_url: if profile_url and sd_url == profile_url:
sd_data = data sd_data = data
found_path = member.name found_path = member.name
logger.info(f"Found SD matching profile '{profile_url}' at path: {found_path}") logger.info(f"Found SD matching profile '{profile_url}' at path: {found_path}")
break break
# Fallback to resource type or identifier match # Broader matching for resource_identifier
elif resource_identifier and ( elif resource_identifier and (
(sd_id and resource_identifier.lower() == sd_id.lower()) or (sd_id and resource_identifier.lower() == sd_id.lower()) or
(sd_name and resource_identifier.lower() == sd_name.lower()) or (sd_name and resource_identifier.lower() == sd_name.lower()) or
(sd_type and resource_identifier.lower() == sd_type.lower()) or (sd_type and resource_identifier.lower() == sd_type.lower()) or
(os.path.splitext(os.path.basename(member.name))[0].lower() == resource_identifier.lower()) # Add fallback for partial filename match
(resource_identifier.lower() in os.path.splitext(os.path.basename(member.name))[0].lower()) or
# Handle AU Core naming conventions
(sd_url and resource_identifier.lower() in sd_url.lower())
): ):
sd_data = data sd_data = data
found_path = member.name found_path = member.name
logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}") logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}")
# Continue searching in case a profile match is found # Continue searching for a profile match
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.debug(f"Could not parse JSON in {member.name}, skipping: {e}") logger.debug(f"Could not parse JSON in {member.name}, skipping: {e}")
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
@ -643,12 +648,25 @@ def navigate_fhir_path(resource, path, extension_url=None):
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True): def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
"""Validates a FHIR resource against a StructureDefinition in the specified package.""" """Validates a FHIR resource against a StructureDefinition in the specified package."""
logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}") logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}, include_dependencies={include_dependencies}")
result = {'valid': True, 'errors': [], 'warnings': []} result = {
'valid': True,
'errors': [],
'warnings': [],
'details': [], # Enhanced info for future use
'resource_type': resource.get('resourceType'),
'resource_id': resource.get('id', 'unknown'),
'profile': resource.get('meta', {}).get('profile', [None])[0]
}
download_dir = _get_download_dir() download_dir = _get_download_dir()
if not download_dir: if not download_dir:
result['valid'] = False result['valid'] = False
result['errors'].append("Could not access download directory") result['errors'].append("Could not access download directory")
result['details'].append({
'issue': "Could not access download directory",
'severity': 'error',
'description': "The server could not locate the directory where FHIR packages are stored."
})
logger.error("Validation failed: Could not access download directory") logger.error("Validation failed: Could not access download directory")
return result return result
@ -657,6 +675,11 @@ def validate_resource_against_profile(package_name, version, resource, include_d
if not os.path.exists(tgz_path): if not os.path.exists(tgz_path):
result['valid'] = False result['valid'] = False
result['errors'].append(f"Package file not found: {package_name}#{version}") result['errors'].append(f"Package file not found: {package_name}#{version}")
result['details'].append({
'issue': f"Package file not found: {package_name}#{version}",
'severity': 'error',
'description': f"The package {package_name}#{version} is not available in the download directory."
})
logger.error(f"Validation failed: Package file not found at {tgz_path}") logger.error(f"Validation failed: Package file not found at {tgz_path}")
return result return result
@ -665,41 +688,85 @@ def validate_resource_against_profile(package_name, version, resource, include_d
meta = resource.get('meta', {}) meta = resource.get('meta', {})
profiles = meta.get('profile', []) profiles = meta.get('profile', [])
if profiles: if profiles:
profile_url = profiles[0] # Use first profile profile_url = profiles[0]
logger.debug(f"Using profile from meta.profile: {profile_url}") logger.debug(f"Using profile from meta.profile: {profile_url}")
# Find StructureDefinition
sd_data, sd_path = find_and_extract_sd(tgz_path, resource.get('resourceType'), profile_url) sd_data, sd_path = find_and_extract_sd(tgz_path, resource.get('resourceType'), profile_url)
if not sd_data and include_dependencies:
logger.debug(f"SD not found in {package_name}#{version}. Checking dependencies.")
try:
with tarfile.open(tgz_path, "r:gz") as tar:
package_json_member = None
for member in tar:
if member.name == 'package/package.json':
package_json_member = member
break
if package_json_member:
fileobj = tar.extractfile(package_json_member)
pkg_data = json.load(fileobj)
fileobj.close()
dependencies = pkg_data.get('dependencies', {})
logger.debug(f"Found dependencies: {dependencies}")
for dep_name, dep_version in dependencies.items():
dep_tgz = os.path.join(download_dir, construct_tgz_filename(dep_name, dep_version))
if os.path.exists(dep_tgz):
logger.debug(f"Searching SD in dependency {dep_name}#{dep_version}")
sd_data, sd_path = find_and_extract_sd(dep_tgz, resource.get('resourceType'), profile_url)
if sd_data:
logger.info(f"Found SD in dependency {dep_name}#{dep_version} at {sd_path}")
break
else:
logger.warning(f"Dependency package {dep_name}#{dep_version} not found at {dep_tgz}")
else:
logger.warning(f"No package.json found in {tgz_path}")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse package.json in {tgz_path}: {e}")
except tarfile.TarError as e:
logger.error(f"Failed to read {tgz_path} while checking dependencies: {e}")
except Exception as e:
logger.error(f"Unexpected error while checking dependencies in {tgz_path}: {e}")
if not sd_data: if not sd_data:
result['valid'] = False result['valid'] = False
result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'} in {package_name}#{version}") result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'}")
result['details'].append({
'issue': f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'}",
'severity': 'error',
'description': f"The package {package_name}#{version} (and dependencies, if checked) does not contain a matching StructureDefinition."
})
logger.error(f"Validation failed: No SD for {resource.get('resourceType')} in {tgz_path}") logger.error(f"Validation failed: No SD for {resource.get('resourceType')} in {tgz_path}")
return result return result
logger.debug(f"Found SD at {sd_path}") logger.debug(f"Found SD at {sd_path}")
# Validate required elements (min=1) # Validate required elements (min=1)
errors = [] errors = []
warnings = set() # Deduplicate warnings
elements = sd_data.get('snapshot', {}).get('element', []) elements = sd_data.get('snapshot', {}).get('element', [])
for element in elements: for element in elements:
path = element.get('path') path = element.get('path')
min_val = element.get('min', 0) min_val = element.get('min', 0)
# Only check top-level required elements must_support = element.get('mustSupport', False)
definition = element.get('definition', 'No definition provided in StructureDefinition.')
# Check required elements
if min_val > 0 and not '.' in path[1 + path.find('.'):]: if min_val > 0 and not '.' in path[1 + path.find('.'):]:
value = navigate_fhir_path(resource, path) value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not any(value)): if value is None or (isinstance(value, list) and not any(value)):
errors.append(f"Required element {path} missing") error_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Required element {path} missing"
errors.append(error_msg)
result['details'].append({
'issue': error_msg,
'severity': 'error',
'description': f"{definition} This element is mandatory (min={min_val}) per the profile {profile_url or 'unknown'}."
})
logger.info(f"Validation error: Required element {path} missing") logger.info(f"Validation error: Required element {path} missing")
# Validate must-support elements # Check must-support elements
warnings = [] if must_support and not '.' in path[1 + path.find('.'):]:
for element in elements:
path = element.get('path')
# Only check top-level must-support elements
if element.get('mustSupport', False) and not '.' in path[1 + path.find('.'):]:
# Handle choice types (e.g., value[x], effective[x])
if '[x]' in path: if '[x]' in path:
base_path = path.replace('[x]', '') base_path = path.replace('[x]', '')
found = False found = False
# Try common choice type suffixes
for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']: for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']:
test_path = f"{base_path}{suffix}" test_path = f"{base_path}{suffix}"
value = navigate_fhir_path(resource, test_path) value = navigate_fhir_path(resource, test_path)
@ -707,17 +774,29 @@ def validate_resource_against_profile(package_name, version, resource, include_d
found = True found = True
break break
if not found: if not found:
warnings.append(f"Must Support element {path} missing or empty") warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty"
warnings.add(warning_msg)
result['details'].append({
'issue': warning_msg,
'severity': 'warning',
'description': f"{definition} This element is marked as Must Support in AU Core, meaning it should be populated if the data is available (e.g., phone or email for Patient.telecom)."
})
logger.info(f"Validation warning: Must Support element {path} missing or empty") logger.info(f"Validation warning: Must Support element {path} missing or empty")
else: else:
value = navigate_fhir_path(resource, path) value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not any(value)): if value is None or (isinstance(value, list) and not any(value)):
# Only warn for non-required must-support elements
if element.get('min', 0) == 0: if element.get('min', 0) == 0:
warnings.append(f"Must Support element {path} missing or empty") warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty"
warnings.add(warning_msg)
result['details'].append({
'issue': warning_msg,
'severity': 'warning',
'description': f"{definition} This element is marked as Must Support in AU Core, meaning it should be populated if the data is available (e.g., phone or email for Patient.telecom)."
})
logger.info(f"Validation warning: Must Support element {path} missing or empty") logger.info(f"Validation warning: Must Support element {path} missing or empty")
# Handle dataAbsentReason only if value[x] is absent
if path.endswith('dataAbsentReason') and element.get('mustSupport', False): # Handle dataAbsentReason for must-support elements
if path.endswith('dataAbsentReason') and must_support:
value_x_path = path.replace('dataAbsentReason', 'value[x]') value_x_path = path.replace('dataAbsentReason', 'value[x]')
value_found = False value_found = False
for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']: for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']:
@ -729,44 +808,106 @@ def validate_resource_against_profile(package_name, version, resource, include_d
if not value_found: if not value_found:
value = navigate_fhir_path(resource, path) value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not any(value)): if value is None or (isinstance(value, list) and not any(value)):
warnings.append(f"Must Support element {path} missing or empty") warning_msg = f"{resource.get('resourceType')}/{resource.get('id', 'unknown')}: Must Support element {path} missing or empty"
warnings.add(warning_msg)
result['details'].append({
'issue': warning_msg,
'severity': 'warning',
'description': f"{definition} This element is marked as Must Support and should be used to indicate why the associated value is absent."
})
logger.info(f"Validation warning: Must Support element {path} missing or empty") logger.info(f"Validation warning: Must Support element {path} missing or empty")
result['valid'] = len(errors) == 0
result['errors'] = errors result['errors'] = errors
result['warnings'] = warnings result['warnings'] = list(warnings)
logger.debug(f"Validation result: valid={result['valid']}, errors={result['errors']}, warnings={result['warnings']}") result['valid'] = len(errors) == 0
result['summary'] = {
'error_count': len(errors),
'warning_count': len(warnings)
}
logger.debug(f"Validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}")
return result return result
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True): def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
"""Validates a FHIR Bundle against profiles in the specified package.""" """Validates a FHIR Bundle against profiles in the specified package."""
logger.debug(f"Validating bundle against {package_name}#{version}") logger.debug(f"Validating bundle against {package_name}#{version}, include_dependencies={include_dependencies}")
result = { result = {
'valid': True, 'valid': True,
'errors': [], 'errors': [],
'warnings': [], 'warnings': [],
'results': {} 'details': [],
'results': {},
'summary': {
'resource_count': 0,
'failed_resources': 0,
'profiles_validated': set()
}
} }
if not bundle.get('resourceType') == 'Bundle': if not bundle.get('resourceType') == 'Bundle':
result['valid'] = False result['valid'] = False
result['errors'].append("Resource is not a Bundle") result['errors'].append("Resource is not a Bundle")
result['details'].append({
'issue': "Resource is not a Bundle",
'severity': 'error',
'description': "The provided resource must have resourceType 'Bundle' to be validated as a bundle."
})
logger.error("Validation failed: Resource is not a Bundle") logger.error("Validation failed: Resource is not a Bundle")
return result return result
# Track references to validate resolvability
references = set()
resolved_references = set()
for entry in bundle.get('entry', []): for entry in bundle.get('entry', []):
resource = entry.get('resource') resource = entry.get('resource')
if not resource: if not resource:
continue continue
resource_type = resource.get('resourceType') resource_type = resource.get('resourceType')
resource_id = resource.get('id', 'unknown') resource_id = resource.get('id', 'unknown')
result['summary']['resource_count'] += 1
# Collect references
for key, value in resource.items():
if isinstance(value, dict) and 'reference' in value:
references.add(value['reference'])
elif isinstance(value, list):
for item in value:
if isinstance(item, dict) and 'reference' in item:
references.add(item['reference'])
# Validate resource
validation_result = validate_resource_against_profile(package_name, version, resource, include_dependencies) validation_result = validate_resource_against_profile(package_name, version, resource, include_dependencies)
result['results'][f"{resource_type}/{resource_id}"] = validation_result result['results'][f"{resource_type}/{resource_id}"] = validation_result
result['summary']['profiles_validated'].add(validation_result['profile'] or 'unknown')
# Aggregate errors and warnings
if not validation_result['valid']: if not validation_result['valid']:
result['valid'] = False result['valid'] = False
result['summary']['failed_resources'] += 1
result['errors'].extend(validation_result['errors']) result['errors'].extend(validation_result['errors'])
result['warnings'].extend(validation_result['warnings']) result['warnings'].extend(validation_result['warnings'])
result['details'].extend(validation_result['details'])
logger.debug(f"Bundle validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}") # Mark resource as resolved if it has an ID
if resource_id != 'unknown':
resolved_references.add(f"{resource_type}/{resource_id}")
# Check for unresolved references
unresolved = references - resolved_references
for ref in unresolved:
warning_msg = f"Unresolved reference: {ref}"
result['warnings'].append(warning_msg)
result['details'].append({
'issue': warning_msg,
'severity': 'warning',
'description': f"The reference {ref} points to a resource not included in the bundle. Ensure the referenced resource is present or resolvable."
})
logger.info(f"Validation warning: Unresolved reference {ref}")
# Finalize summary
result['summary']['profiles_validated'] = list(result['summary']['profiles_validated'])
result['summary']['error_count'] = len(result['errors'])
result['summary']['warning_count'] = len(result['warnings'])
logger.debug(f"Bundle validation result: valid={result['valid']}, errors={result['summary']['error_count']}, warnings={result['summary']['warning_count']}, resources={result['summary']['resource_count']}")
return result return result
# --- Structure Definition Retrieval --- # --- Structure Definition Retrieval ---

View File

@ -3,7 +3,6 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<!-- Left Column: List of Downloaded IGs -->
<div class="col-md-6"> <div class="col-md-6">
<h2>Downloaded IGs</h2> <h2>Downloaded IGs</h2>
{% if packages %} {% if packages %}
@ -44,7 +43,6 @@
{% endif %} {% endif %}
</div> </div>
<!-- Right Column: Push IGs Form -->
<div class="col-md-6"> <div class="col-md-6">
<h2>Push IGs to FHIR Server</h2> <h2>Push IGs to FHIR Server</h2>
<form id="pushIgForm" method="POST"> <form id="pushIgForm" method="POST">
@ -73,7 +71,6 @@
<button type="submit" class="btn btn-primary" id="pushButton">Push to FHIR Server</button> <button type="submit" class="btn btn-primary" id="pushButton">Push to FHIR Server</button>
</form> </form>
<!-- Live Console -->
<div class="mt-3"> <div class="mt-3">
<h4>Live Console</h4> <h4>Live Console</h4>
<div id="liveConsole" class="border p-3 bg-dark text-light" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 14px;"> <div id="liveConsole" class="border p-3 bg-dark text-light" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 14px;">
@ -81,7 +78,6 @@
</div> </div>
</div> </div>
<!-- Response Area -->
<div id="pushResponse" class="mt-3"> <div id="pushResponse" class="mt-3">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@ -99,7 +95,11 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const packageSelect = document.getElementById('packageSelect'); const packageSelect = document.getElementById('packageSelect');
const dependencyModeField = document.getElementById('dependencyMode'); const dependencyModeField = document.getElementById('dependencyMode');
const csrfToken = "{{ form.csrf_token._value() }}"; // Ensure form object exists before accessing csrf_token, handle potential errors
const csrfToken = typeof form !== 'undefined' && form.csrf_token ? "{{ form.csrf_token._value() }}" : document.querySelector('input[name="csrf_token"]')?.value || "";
if (!csrfToken) {
console.warn("CSRF token not found in template or hidden input.");
}
// Update dependency mode when package selection changes // Update dependency mode when package selection changes
packageSelect.addEventListener('change', function() { packageSelect.addEventListener('change', function() {
@ -107,16 +107,23 @@ document.addEventListener('DOMContentLoaded', function() {
if (packageId) { if (packageId) {
const [packageName, version] = packageId.split('#'); const [packageName, version] = packageId.split('#');
fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`) fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`)
.then(response => response.json()) .then(response => {
if (!response.ok) {
throw new Error(`Metadata fetch failed: ${response.status}`);
}
return response.json();
})
.then(data => { .then(data => {
if (data.dependency_mode) { // Check specifically for the dependency_mode key in the response data
dependencyModeField.value = data.dependency_mode; if (data && typeof data === 'object' && data.hasOwnProperty('dependency_mode')) {
dependencyModeField.value = data.dependency_mode !== null ? data.dependency_mode : 'N/A';
} else { } else {
dependencyModeField.value = 'Unknown'; dependencyModeField.value = 'Unknown'; // Or 'N/A' if metadata exists but no mode
console.warn('Dependency mode not found in metadata response:', data);
} }
}) })
.catch(error => { .catch(error => {
console.error('Error fetching metadata:', error); console.error('Error fetching or processing metadata:', error);
dependencyModeField.value = 'Error'; dependencyModeField.value = 'Error';
}); });
} else { } else {
@ -129,16 +136,27 @@ document.addEventListener('DOMContentLoaded', function() {
packageSelect.dispatchEvent(new Event('change')); packageSelect.dispatchEvent(new Event('change'));
} }
// --- Push IG Form Submission ---
document.getElementById('pushIgForm').addEventListener('submit', async function(event) { document.getElementById('pushIgForm').addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
const pushButton = document.getElementById('pushButton'); const pushButton = document.getElementById('pushButton');
const formData = new FormData(form); const formData = new FormData(form); // Use FormData to easily get values
const packageId = formData.get('package_id'); const packageId = formData.get('package_id');
const [packageName, version] = packageId.split('#');
const fhirServerUrl = formData.get('fhir_server_url'); const fhirServerUrl = formData.get('fhir_server_url');
const includeDependencies = formData.get('include_dependencies') === 'on'; const includeDependencies = formData.get('include_dependencies') === 'on'; // Checkbox value is 'on' if checked
if (!packageId) {
alert('Please select a package to push.');
return;
}
if (!fhirServerUrl) {
alert('Please enter the FHIR Server URL.');
return;
}
const [packageName, version] = packageId.split('#');
// Disable the button to prevent multiple submissions // Disable the button to prevent multiple submissions
pushButton.disabled = true; pushButton.disabled = true;
@ -147,55 +165,75 @@ document.addEventListener('DOMContentLoaded', function() {
// Clear the console and response area // Clear the console and response area
const liveConsole = document.getElementById('liveConsole'); const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('pushResponse'); const responseDiv = document.getElementById('pushResponse');
liveConsole.innerHTML = '<div>Starting push operation...</div>'; liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting push operation for ${packageName}#${version}...</div>`;
responseDiv.innerHTML = ''; responseDiv.innerHTML = ''; // Clear previous final response
// Get API Key from template context - ensure 'api_key' is passed correctly from Flask route
const apiKey = '{{ api_key | default("") | safe }}';
if (!apiKey) {
console.warn("API Key not found in template context. Ensure 'api_key' is passed to render_template.");
// Optionally display a warning to the user if needed
}
try { try {
const response = await fetch('/api/push-ig', { const response = await fetch('/api/push-ig', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/x-ndjson', 'Accept': 'application/x-ndjson', // We expect NDJSON stream
'X-CSRFToken': csrfToken 'X-CSRFToken': csrfToken, // Include CSRF token from form/template
'X-API-Key': apiKey // Send API Key in header (alternative to body)
}, },
body: JSON.stringify({ body: JSON.stringify({ // Still send details in body
package_name: packageName, package_name: packageName,
version: version, version: version,
fhir_server_url: fhirServerUrl, fhir_server_url: fhirServerUrl,
include_dependencies: includeDependencies, include_dependencies: includeDependencies
api_key: '{{ api_key | safe }}' // Removed api_key from body as it's now in header
}) })
}); });
if (!response.ok) { // Handle immediate errors before trying to read the stream
const errorData = await response.json(); if (!response.ok) {
throw new Error(errorData.message || 'Failed to push package'); let errorMsg = `HTTP error! status: ${response.status}`;
} try {
// Try to parse a JSON error response from the server
const errorData = await response.json();
errorMsg = errorData.message || JSON.stringify(errorData);
} catch (e) {
// If response is not JSON, use the status text
errorMsg = response.statusText || errorMsg;
}
throw new Error(errorMsg);
}
// Stream the response // --- Stream Processing ---
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break; // Exit loop when stream is finished
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true }); // Decode chunk and append to buffer
const lines = buffer.split('\n'); const lines = buffer.split('\n'); // Split buffer into lines
buffer = lines.pop(); buffer = lines.pop(); // Keep potential partial line for next chunk
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue; if (!line.trim()) continue; // Skip empty lines
try { try {
const data = JSON.parse(line); const data = JSON.parse(line); // Parse each line as JSON
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
let messageClass = ''; let messageClass = 'text-light'; // Default text color for console
let prefix = ''; let prefix = '[INFO]'; // Default prefix for console
// --- Switch based on message type from backend ---
switch (data.type) { switch (data.type) {
case 'start': case 'start':
case 'progress': case 'progress':
case 'info':
messageClass = 'text-info'; messageClass = 'text-info';
prefix = '[INFO]'; prefix = '[INFO]';
break; break;
@ -212,133 +250,212 @@ document.addEventListener('DOMContentLoaded', function() {
prefix = '[WARNING]'; prefix = '[WARNING]';
break; break;
case 'complete': case 'complete':
if (data.data.status === 'success') { // ---vvv THIS IS THE CORRECTED BLOCK vvv---
responseDiv.innerHTML = ` // Process the final summary data
<div class="alert alert-success"> const summaryData = data.data; // data.data contains the summary object
<strong>Success:</strong> ${data.data.message}<br> let alertClass = 'alert-info'; // Bootstrap class for responseDiv
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br> let statusText = 'Info';
<strong>Server Response:</strong> ${data.data.server_response} let pushedPackagesList = 'None';
</div> let failureDetailsHtml = ''; // Initialize failure details HTML
`;
} else if (data.data.status === 'partial') {
responseDiv.innerHTML = `
<div class="alert alert-warning">
<strong>Partial Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else {
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> ${data.data.message}
</div>
`;
}
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger');
prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break;
default:
messageClass = 'text-light';
prefix = '[INFO]';
}
// Format pushed packages summary (using correct field name)
if (summaryData.pushed_packages_summary && summaryData.pushed_packages_summary.length > 0) {
pushedPackagesList = summaryData.pushed_packages_summary.map(pkg => `${pkg.id} (${pkg.resource_count} resources)`).join('<br>');
}
// Determine alert class based on status
if (summaryData.status === 'success') {
alertClass = 'alert-success';
statusText = 'Success';
prefix = '[SUCCESS]'; // For console log
} else if (summaryData.status === 'partial') {
alertClass = 'alert-warning';
statusText = 'Partial Success';
prefix = '[PARTIAL]'; // For console log
} else { // failure or error
alertClass = 'alert-danger';
statusText = 'Error';
prefix = '[ERROR]'; // For console log
}
// Format failure details if present (using correct field name)
if (summaryData.failed_details && summaryData.failed_details.length > 0) {
failureDetailsHtml += '<hr><strong>Failure Details:</strong><ul style="font-size: 0.9em; max-height: 150px; overflow-y: auto;">';
summaryData.failed_details.forEach(fail => {
const escapedResource = fail.resource ? fail.resource.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "N/A";
const escapedError = fail.error ? fail.error.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "Unknown error";
failureDetailsHtml += `<li><strong>${escapedResource}:</strong> ${escapedError}</li>`;
});
failureDetailsHtml += '</ul>';
}
// Update the final response area (pushResponse div)
responseDiv.innerHTML = `
<div class="alert ${alertClass}">
<strong>${statusText}:</strong> ${summaryData.message || 'Operation complete.'}<br>
<hr>
<strong>Target Server:</strong> ${summaryData.target_server || 'N/A'}<br>
<strong>Package:</strong> ${summaryData.package_name || 'N/A'}#${summaryData.version || 'N/A'}<br>
<strong>Included Dependencies:</strong> ${summaryData.included_dependencies ? 'Yes' : 'No'}<br>
<strong>Resources Attempted:</strong> ${summaryData.resources_attempted !== undefined ? summaryData.resources_attempted : 'N/A'}<br>
<strong>Succeeded:</strong> ${summaryData.success_count !== undefined ? summaryData.success_count : 'N/A'}<br>
<strong>Failed:</strong> ${summaryData.failure_count !== undefined ? summaryData.failure_count : 'N/A'}<br>
<strong>Packages Pushed Summary:</strong><br><div style="padding-left: 15px;">${pushedPackagesList}</div>
${failureDetailsHtml} </div>
`;
// Also set the final message class for the console log
messageClass = alertClass.replace('alert-', 'text-');
break; // Break from switch
// ---^^^ END OF CORRECTED BLOCK ^^^---
default:
console.warn("Received unknown message type:", data.type);
prefix = '[UNKNOWN]';
break;
} // End Switch
// --- Add message to Live Console ---
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
messageDiv.className = messageClass; messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`; // Use data.message for most types, use summary message for complete type
// Check if data.data exists for complete type before accessing its message
const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message;
messageDiv.textContent = `${timestamp} ${prefix} ${messageText || 'Received empty message.'}`;
liveConsole.appendChild(messageDiv);
liveConsole.scrollTop = liveConsole.scrollHeight; // Scroll to bottom
} catch (parseError) { // Handle errors parsing individual JSON lines
console.error('Error parsing streamed message:', parseError, 'Line:', line);
const timestamp = new Date().toLocaleTimeString();
const errorDiv = document.createElement('div');
errorDiv.className = 'text-danger';
errorDiv.textContent = `${timestamp} [PARSE_ERROR] Could not parse message: ${line.substring(0, 100)}${line.length > 100 ? '...' : ''}`; // Show partial line
liveConsole.appendChild(errorDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
}
} // end for loop over lines
} // end while loop over stream
// --- Process Final Buffer ---
// (Repeat the parsing logic for any remaining data in the buffer)
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
const timestamp = new Date().toLocaleTimeString();
let messageClass = 'text-light'; // Default for console
let prefix = '[INFO]'; // Default for console
switch (data.type) {
case 'start': // Less likely in final buffer, but handle defensively
case 'progress':
case 'info':
messageClass = 'text-info';
prefix = '[INFO]';
break;
case 'success':
messageClass = 'text-success';
prefix = '[SUCCESS]';
break;
case 'error':
messageClass = 'text-danger';
prefix = '[ERROR]';
break;
case 'warning':
messageClass = 'text-warning';
prefix = '[WARNING]';
break;
// ---vvv THIS IS THE CORRECTED BLOCK vvv---
case 'complete':
const summaryData = data.data;
let alertClass = 'alert-info';
let statusText = 'Info';
let pushedPackagesList = 'None';
let failureDetailsHtml = '';
if (summaryData.pushed_packages_summary && summaryData.pushed_packages_summary.length > 0) {
pushedPackagesList = summaryData.pushed_packages_summary.map(pkg => `${pkg.id} (${pkg.resource_count} resources)`).join('<br>');
}
if (summaryData.status === 'success') {
alertClass = 'alert-success'; statusText = 'Success'; prefix = '[SUCCESS]';
} else if (summaryData.status === 'partial') {
alertClass = 'alert-warning'; statusText = 'Partial Success'; prefix = '[PARTIAL]';
} else {
alertClass = 'alert-danger'; statusText = 'Error'; prefix = '[ERROR]';
}
if (summaryData.failed_details && summaryData.failed_details.length > 0) {
failureDetailsHtml += '<hr><strong>Failure Details:</strong><ul style="font-size: 0.9em; max-height: 150px; overflow-y: auto;">';
summaryData.failed_details.forEach(fail => {
const escapedResource = fail.resource ? fail.resource.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "N/A";
const escapedError = fail.error ? fail.error.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "Unknown error";
failureDetailsHtml += `<li><strong>${escapedResource}:</strong> ${escapedError}</li>`;
});
failureDetailsHtml += '</ul>';
}
responseDiv.innerHTML = `
<div class="alert ${alertClass}">
<strong>${statusText}:</strong> ${summaryData.message || 'Operation complete.'}<br>
<hr>
<strong>Target Server:</strong> ${summaryData.target_server || 'N/A'}<br>
<strong>Package:</strong> ${summaryData.package_name || 'N/A'}#${summaryData.version || 'N/A'}<br>
<strong>Included Dependencies:</strong> ${summaryData.included_dependencies ? 'Yes' : 'No'}<br>
<strong>Resources Attempted:</strong> ${summaryData.resources_attempted !== undefined ? summaryData.resources_attempted : 'N/A'}<br>
<strong>Succeeded:</strong> ${summaryData.success_count !== undefined ? summaryData.success_count : 'N/A'}<br>
<strong>Failed:</strong> ${summaryData.failure_count !== undefined ? summaryData.failure_count : 'N/A'}<br>
<strong>Packages Pushed Summary:</strong><br><div style="padding-left: 15px;">${pushedPackagesList}</div>
${failureDetailsHtml}
</div>
`;
messageClass = alertClass.replace('alert-', 'text-');
break;
// ---^^^ END OF CORRECTED BLOCK ^^^---
default:
console.warn("Received unknown message type in final buffer:", data.type);
prefix = '[UNKNOWN]';
break;
} // End Switch
// Add final buffer message to live console
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message;
messageDiv.textContent = `${timestamp} ${prefix} ${messageText || 'Received empty message.'}`;
liveConsole.appendChild(messageDiv); liveConsole.appendChild(messageDiv);
liveConsole.scrollTop = liveConsole.scrollHeight; liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) {
console.error('Error parsing streamed message:', parseError, 'Line:', line); } catch (parseError) { // Handle error parsing final buffer
console.error('Error parsing final streamed message:', parseError, 'Buffer:', buffer);
const timestamp = new Date().toLocaleTimeString();
const errorDiv = document.createElement('div');
errorDiv.className = 'text-danger';
errorDiv.textContent = `${timestamp} [PARSE_ERROR] Could not parse final message: ${buffer.substring(0, 100)}${buffer.length > 100 ? '...' : ''}`;
liveConsole.appendChild(errorDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
} }
} } // end if buffer trim
}
if (buffer.trim()) { } catch (error) { // Handle fetch errors or errors thrown during initial response check
try { console.error("Push operation failed:", error); // Log the error object
const data = JSON.parse(buffer);
const timestamp = new Date().toLocaleTimeString();
let messageClass = '';
let prefix = '';
switch (data.type) {
case 'start':
case 'progress':
messageClass = 'text-info';
prefix = '[INFO]';
break;
case 'success':
messageClass = 'text-success';
prefix = '[SUCCESS]';
break;
case 'error':
messageClass = 'text-danger';
prefix = '[ERROR]';
break;
case 'warning':
messageClass = 'text-warning';
prefix = '[WARNING]';
break;
case 'complete':
if (data.data.status === 'success') {
responseDiv.innerHTML = `
<div class="alert alert-success">
<strong>Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else if (data.data.status === 'partial') {
responseDiv.innerHTML = `
<div class="alert alert-warning">
<strong>Partial Success:</strong> ${data.data.message}<br>
<strong>Pushed Packages:</strong> ${data.data.pushed_packages.join(', ')}<br>
<strong>Server Response:</strong> ${data.data.server_response}
</div>
`;
} else {
responseDiv.innerHTML = `
<div class="alert alert-danger">
<strong>Error:</strong> ${data.data.message}
</div>
`;
}
messageClass = data.data.status === 'success' ? 'text-success' : (data.data.status === 'partial' ? 'text-warning' : 'text-danger');
prefix = data.data.status === 'success' ? '[SUCCESS]' : (data.data.status === 'partial' ? '[PARTIAL]' : '[ERROR]');
break;
default:
messageClass = 'text-light';
prefix = '[INFO]';
}
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown message'}`;
liveConsole.appendChild(messageDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
} catch (parseError) {
console.error('Error parsing final streamed message:', parseError, 'Buffer:', buffer);
}
}
} catch (error) {
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
const errorDiv = document.createElement('div'); const errorDiv = document.createElement('div');
errorDiv.className = 'text-danger'; errorDiv.className = 'text-danger';
errorDiv.textContent = `${timestamp} [ERROR] Failed to push package: ${error.message}`; errorDiv.textContent = `${timestamp} [FETCH_ERROR] Failed to push package: ${error.message}`;
liveConsole.appendChild(errorDiv); liveConsole.appendChild(errorDiv);
liveConsole.scrollTop = liveConsole.scrollHeight; liveConsole.scrollTop = liveConsole.scrollHeight;
// Display error message in the response area
responseDiv.innerHTML = ` responseDiv.innerHTML = `
<div class="alert alert-danger"> <div class="alert alert-danger">
<strong>Error:</strong> Failed to push package: ${error.message} <strong>Error:</strong> ${error.message}
</div> </div>
`; `;
} finally { } finally {
// Re-enable the button regardless of success or failure
pushButton.disabled = false; pushButton.disabled = false;
pushButton.textContent = 'Push to FHIR Server'; pushButton.textContent = 'Push to FHIR Server';
} }
}); }); // End form submit listener
}); }); // End DOMContentLoaded listener
</script> </script>
{% endblock %} {% endblock %}

View File

@ -145,13 +145,15 @@
<div id="raw-structure-wrapper" class="mt-4" style="display: none;"> <div id="raw-structure-wrapper" class="mt-4" style="display: none;">
<div class="card"> <div class="card">
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div> <div class="card-header d-flex justify-content-between align-items-center">
<span>Raw Structure Definition for <code id="raw-structure-title"></code></span>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" id="copy-raw-def-button"
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Definition JSON">
<i class="bi bi-clipboard" aria-hidden="true"></i>
<span class="visually-hidden">Copy Raw Definition JSON to clipboard</span>
</button>
</div>
<div class="card-body"> <div class="card-body">
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button"
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON">
<i class="bi bi-clipboard" aria-hidden="true"></i>
<span class="visually-hidden">Copy JSON to clipboard</span>
</button>
<div id="raw-structure-loading" class="text-center py-3" style="display: none;"> <div id="raw-structure-loading" class="text-center py-3" style="display: none;">
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div> <div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
</div> </div>
@ -175,21 +177,23 @@
<h6 id="example-filename" class="mt-2 small text-muted"></h6> <h6 id="example-filename" class="mt-2 small text-muted"></h6>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<h6>Raw Content</h6> <div class="d-flex justify-content-between align-items-center mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button" <h6>Raw Content</h6>
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON"> <button type="button" class="btn btn-sm btn-outline-secondary" id="copy-raw-content-button"
<i class="bi bi-clipboard" aria-hidden="true"></i> data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Content">
<span class="visually-hidden">Copy JSON to clipboard</span> <i class="bi bi-clipboard" aria-hidden="true"></i>
</button> <span class="visually-hidden">Copy raw example content to clipboard</span>
</button>
</div>
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre> <pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<h6>Pretty-Printed JSON</h6> <h6>Pretty-Printed JSON</h6>
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button" <button type="button" class="btn btn-sm btn-outline-secondary" id="copy-pretty-json-button"
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON"> data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON">
<i class="bi bi-clipboard" aria-hidden="true"></i> <i class="bi bi-clipboard" aria-hidden="true"></i>
<span class="visually-hidden">Copy JSON to clipboard</span> <span class="visually-hidden">Copy JSON example to clipboard</span>
</button> </button>
</div> </div>
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre> <pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
@ -208,7 +212,6 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
try { try {
// Existing element constants...
const structureDisplayWrapper = document.getElementById('structure-display-wrapper'); const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
const structureDisplay = document.getElementById('structure-content'); const structureDisplay = document.getElementById('structure-content');
const structureTitle = document.getElementById('structure-title'); const structureTitle = document.getElementById('structure-title');
@ -222,17 +225,81 @@ document.addEventListener('DOMContentLoaded', function() {
const exampleContentWrapper = document.getElementById('example-content-wrapper'); const exampleContentWrapper = document.getElementById('example-content-wrapper');
const exampleFilename = document.getElementById('example-filename'); const exampleFilename = document.getElementById('example-filename');
const exampleContentRaw = document.getElementById('example-content-raw'); const exampleContentRaw = document.getElementById('example-content-raw');
const exampleContentJson = document.getElementById('example-content-json'); // Already exists const exampleContentJson = document.getElementById('example-content-json');
const rawStructureWrapper = document.getElementById('raw-structure-wrapper'); const rawStructureWrapper = document.getElementById('raw-structure-wrapper');
const rawStructureTitle = document.getElementById('raw-structure-title'); const rawStructureTitle = document.getElementById('raw-structure-title');
const rawStructureContent = document.getElementById('raw-structure-content'); const rawStructureContent = document.getElementById('raw-structure-content');
const rawStructureLoading = document.getElementById('raw-structure-loading'); const rawStructureLoading = document.getElementById('raw-structure-loading');
// --- Added: Buttons & Tooltips Constants (Using Corrected IDs) ---
const copyRawDefButton = document.getElementById('copy-raw-def-button');
const copyRawExButton = document.getElementById('copy-raw-content-button');
const copyPrettyJsonButton = document.getElementById('copy-pretty-json-button');
let copyRawDefTooltipInstance = null;
let copyRawExTooltipInstance = null;
let copyPrettyJsonTooltipInstance = null;
// Base URLs...
const structureBaseUrl = "{{ url_for('get_structure_definition') }}"; const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
const exampleBaseUrl = "{{ url_for('get_example_content') }}"; const exampleBaseUrl = "{{ url_for('get_example_content') }}";
// Existing Structure definition fetching and display logic... // --- Added: Generic Copy Logic Function ---
function setupCopyButton(buttonElement, sourceElement, tooltipInstance, successTitle, originalTitle, errorTitle, nothingTitle) {
if (!buttonElement || !sourceElement) {
console.warn("Copy button or source element not found for setup:", buttonElement?.id, sourceElement?.id);
return null;
}
// Dispose previous instance if exists, then create new one
bootstrap.Tooltip.getInstance(buttonElement)?.dispose();
tooltipInstance = new bootstrap.Tooltip(buttonElement);
// Store handler reference to potentially remove later if needed
const clickHandler = () => {
const textToCopy = sourceElement.textContent;
const copyIcon = buttonElement.querySelector('i');
console.log(`Copy button clicked: ${buttonElement.id}`);
if (textToCopy && textToCopy.trim() && textToCopy !== '(Not valid JSON)') {
navigator.clipboard.writeText(textToCopy).then(() => {
console.log(` > Content copied successfully!`);
buttonElement.setAttribute('data-bs-original-title', successTitle);
tooltipInstance.show();
if (copyIcon) copyIcon.classList.replace('bi-clipboard', 'bi-check-lg');
setTimeout(() => {
if (buttonElement.isConnected) {
tooltipInstance.hide();
buttonElement.setAttribute('data-bs-original-title', originalTitle);
if (copyIcon) copyIcon.classList.replace('bi-check-lg', 'bi-clipboard');
}
}, 2000);
}).catch(err => {
console.error(` > Failed to copy content for ${buttonElement.id}:`, err);
buttonElement.setAttribute('data-bs-original-title', errorTitle);
tooltipInstance.show();
setTimeout(() => {
if (buttonElement.isConnected) {
tooltipInstance.hide();
buttonElement.setAttribute('data-bs-original-title', originalTitle);
}
}, 2000);
});
} else {
console.log(' > No valid content to copy.');
buttonElement.setAttribute('data-bs-original-title', nothingTitle);
tooltipInstance.show();
setTimeout(() => {
if (buttonElement.isConnected) {
tooltipInstance.hide();
buttonElement.setAttribute('data-bs-original-title', originalTitle);
}
}, 1500);
}
};
// Remove potentially old listener - best effort without storing reference
// buttonElement.removeEventListener('click', clickHandler); // May not work reliably without named fn
buttonElement.addEventListener('click', clickHandler); // Add the listener
return tooltipInstance;
}
// --- End Added ---
document.querySelectorAll('.resource-type-list').forEach(container => { document.querySelectorAll('.resource-type-list').forEach(container => {
container.addEventListener('click', function(event) { container.addEventListener('click', function(event) {
const link = event.target.closest('.resource-type-link'); const link = event.target.closest('.resource-type-link');
@ -261,40 +328,34 @@ document.addEventListener('DOMContentLoaded', function() {
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
structureDisplayWrapper.style.display = 'block'; structureDisplayWrapper.style.display = 'block';
rawStructureWrapper.style.display = 'block'; rawStructureWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially exampleDisplayWrapper.style.display = 'none';
fetch(structureFetchUrl) fetch(structureFetchUrl)
.then(response => { .then(response => {
console.log("Structure fetch response status:", response.status); console.log("Structure fetch response status:", response.status);
// Use response.json() directly, check ok status in the next .then() return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
if (!response.ok) {
// Attempt to parse error JSON first
return response.json().catch(() => null).then(errorData => {
throw new Error(errorData?.error || `HTTP error ${response.status}`);
});
}
return response.json();
}) })
.then(data => { // No longer destructuring result here, just using data .then(result => {
console.log("Structure data received:", data); if (!result.ok) {
if (data.fallback_used) { throw new Error(result.data.error || `HTTP error ${result.status}`);
}
console.log("Structure data received:", result.data);
if (result.data.fallback_used) {
structureFallbackMessage.style.display = 'block'; structureFallbackMessage.style.display = 'block';
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${data.source_package}.`; structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`;
} else { } else {
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
} }
renderStructureTree(data.elements, data.must_support_paths || [], resourceType); renderStructureTree(result.data.elements, result.data.must_support_paths || [], resourceType);
rawStructureContent.textContent = JSON.stringify(data, null, 2); // Changed result.data to data rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
populateExampleSelector(resourceType); // Show/hide example section based on availability populateExampleSelector(resourceType);
}) })
.catch(error => { .catch(error => {
console.error("Error fetching/processing structure:", error); console.error("Error fetching structure:", error);
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`; structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
rawStructureContent.textContent = `Error loading structure: ${error.message}`; rawStructureContent.textContent = `Error loading structure: ${error.message}`;
// Still try to populate examples, might be useful even if structure fails? Or hide it? populateExampleSelector(resourceType);
// Let's hide it if structure fails completely
exampleDisplayWrapper.style.display = 'none';
}) })
.finally(() => { .finally(() => {
structureLoading.style.display = 'none'; structureLoading.style.display = 'none';
@ -303,7 +364,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Existing function populateExampleSelector...
function populateExampleSelector(resourceOrProfileIdentifier) { function populateExampleSelector(resourceOrProfileIdentifier) {
console.log("Populating examples for:", resourceOrProfileIdentifier); console.log("Populating examples for:", resourceOrProfileIdentifier);
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier; exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
@ -324,14 +384,13 @@ document.addEventListener('DOMContentLoaded', function() {
exampleSelect.appendChild(option); exampleSelect.appendChild(option);
}); });
exampleSelectorWrapper.style.display = 'block'; exampleSelectorWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'block'; // Show the whole example section exampleDisplayWrapper.style.display = 'block';
} else { } else {
exampleSelectorWrapper.style.display = 'none'; exampleSelectorWrapper.style.display = 'none';
exampleDisplayWrapper.style.display = 'none'; // Hide the whole example section if none available exampleDisplayWrapper.style.display = 'none';
} }
} }
// Existing event listener for exampleSelect...
if (exampleSelect) { if (exampleSelect) {
exampleSelect.addEventListener('change', function(event) { exampleSelect.addEventListener('change', function(event) {
const selectedFilePath = this.value; const selectedFilePath = this.value;
@ -341,6 +400,15 @@ document.addEventListener('DOMContentLoaded', function() {
exampleFilename.textContent = ''; exampleFilename.textContent = '';
exampleContentWrapper.style.display = 'none'; exampleContentWrapper.style.display = 'none';
// --- ADDED: Reset Example Copy Button Tooltips ---
if (copyRawExTooltipInstance && copyRawExButton?.isConnected) {
copyRawExTooltipInstance.hide(); copyRawExButton.setAttribute('data-bs-original-title', 'Copy Raw Content');
copyRawExButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); }
if (copyPrettyJsonTooltipInstance && copyPrettyJsonButton?.isConnected) {
copyPrettyJsonTooltipInstance.hide(); copyPrettyJsonButton.setAttribute('data-bs-original-title', 'Copy JSON');
copyPrettyJsonButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); }
// --- END ADDED ---
if (!selectedFilePath) return; if (!selectedFilePath) return;
const exampleParams = new URLSearchParams({ const exampleParams = new URLSearchParams({
@ -386,94 +454,12 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// --- START: Add Copy Button Logic --- // --- ADDED: Setup Copy Buttons Calls---
const copyJsonButton = document.getElementById('copy-json-button'); copyRawDefTooltipInstance = setupCopyButton(copyRawDefButton, rawStructureContent, copyRawDefTooltipInstance, 'Copied Definition!', 'Copy Raw Definition JSON', 'Copy Failed!', 'Nothing to copy');
// const jsonCodeElement = document.getElementById('example-content-json'); // Already defined above copyRawExTooltipInstance = setupCopyButton(copyRawExButton, exampleContentRaw, copyRawExTooltipInstance, 'Copied Raw!', 'Copy Raw Content', 'Copy Failed!', 'Nothing to copy');
let copyTooltipInstance = null; // To hold the tooltip object copyPrettyJsonTooltipInstance = setupCopyButton(copyPrettyJsonButton, exampleContentJson, copyPrettyJsonTooltipInstance, 'Copied JSON!', 'Copy JSON', 'Copy Failed!', 'Nothing to copy');
// --- END ADDED ---
if (copyJsonButton && exampleContentJson) { // Use exampleContentJson here
// Initialize the Bootstrap Tooltip *once*
copyTooltipInstance = new bootstrap.Tooltip(copyJsonButton);
copyJsonButton.addEventListener('click', () => {
const textToCopy = exampleContentJson.textContent; // Use the correct element
const originalTitle = 'Copy JSON';
const successTitle = 'Copied!';
const copyIcon = copyJsonButton.querySelector('i'); // Get the icon element
if (textToCopy && textToCopy.trim() && textToCopy !== '(Not valid JSON)') {
navigator.clipboard.writeText(textToCopy).then(() => {
console.log('JSON copied to clipboard');
// Update tooltip to show success
copyJsonButton.setAttribute('data-bs-original-title', successTitle);
copyTooltipInstance.show();
// Change icon to checkmark
if (copyIcon) copyIcon.classList.replace('bi-clipboard', 'bi-check-lg');
// Reset tooltip and icon after a delay
setTimeout(() => {
copyTooltipInstance.hide();
// Check if the tooltip still exists before resetting title
if(copyJsonButton.isConnected) {
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
if (copyIcon) copyIcon.classList.replace('bi-check-lg', 'bi-clipboard');
}
}, 2000); // Reset after 2 seconds
}).catch(err => {
console.error('Failed to copy JSON: ', err);
// Optional: Show error feedback via tooltip
copyJsonButton.setAttribute('data-bs-original-title', 'Copy Failed!');
copyTooltipInstance.show();
setTimeout(() => {
// Check if the tooltip still exists before resetting title
if(copyJsonButton.isConnected) {
copyTooltipInstance.hide();
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
}
}, 2000);
});
} else {
console.log('No valid JSON content to copy.');
// Optional: Show feedback if there's nothing to copy
copyJsonButton.setAttribute('data-bs-original-title', 'Nothing to copy');
copyTooltipInstance.show();
setTimeout(() => {
// Check if the tooltip still exists before resetting title
if(copyJsonButton.isConnected) {
copyTooltipInstance.hide();
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
}
}, 1500);
}
});
// Optional: Hide the tooltip if the user selects a new example
// to prevent the "Copied!" message from sticking around incorrectly.
if (exampleSelect) {
exampleSelect.addEventListener('change', () => {
if (copyTooltipInstance) {
copyTooltipInstance.hide();
// Check if the button still exists before resetting title/icon
if(copyJsonButton.isConnected) {
copyJsonButton.setAttribute('data-bs-original-title', 'Copy JSON');
const copyIcon = copyJsonButton.querySelector('i');
if (copyIcon) copyIcon.className = 'bi bi-clipboard'; // Reset icon
}
}
});
}
} else {
console.warn("Could not find copy button or JSON code element for copy functionality.");
}
// --- END: Add Copy Button Logic ---
// Existing tree rendering functions (buildTreeData, renderNodeAsLi, renderStructureTree)...
// Make sure these are correctly placed within the DOMContentLoaded scope
function buildTreeData(elements) { function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' }; const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot }; const nodeMap = { 'Root': treeRoot };
@ -484,7 +470,7 @@ document.addEventListener('DOMContentLoaded', function() {
const id = el.id || null; const id = el.id || null;
const sliceName = el.sliceName || null; const sliceName = el.sliceName || null;
// console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`); // DEBUG console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`);
if (!path) { if (!path) {
console.warn(`Skipping element ${index} with no path`); console.warn(`Skipping element ${index} with no path`);
@ -494,110 +480,134 @@ document.addEventListener('DOMContentLoaded', function() {
const parts = path.split('.'); const parts = path.split('.');
let currentPath = ''; let currentPath = '';
let parentNode = treeRoot; let parentNode = treeRoot;
let parentPath = 'Root'; // Keep track of the parent's logical path
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
let part = parts[i]; let part = parts[i];
const isChoiceElement = part.endsWith('[x]'); const isChoiceElement = part.endsWith('[x]');
const cleanPart = part.replace(/\[\d+\]/g, '').replace('[x]', ''); const cleanPart = part.replace(/\[\d+\]/g, '').replace('[x]', '');
const currentPathSegment = i === 0 ? cleanPart : `${parentPath}.${cleanPart}`; // Build path segment by segment currentPath = i === 0 ? cleanPart : `${currentPath}.${cleanPart}`;
let nodeKey = isChoiceElement ? part : cleanPart;
let nodeKey = cleanPart; // Usually the last part of the path segment // Handle slices using id for accurate parent lookup
let nodeId = id ? id : currentPathSegment; // Use element.id if available for map key if (sliceName && i === parts.length - 1) {
// If this is the last part and it's a slice, use sliceName as the display name (nodeKey)
// and element.id as the unique identifier (nodeId) in the map.
if (sliceName && i === parts.length - 1 && id) {
nodeKey = sliceName; nodeKey = sliceName;
nodeId = id; // Use the full ID like "Observation.component:systolic.value[x]" currentPath = id || currentPath;
} else if (isChoiceElement) {
nodeKey = part; // Display 'value[x]' etc.
nodeId = currentPathSegment; // Map uses path like 'Observation.value[x]'
} else {
nodeId = currentPathSegment; // Map uses path like 'Observation.status'
} }
if (!nodeMap[currentPath]) {
if (!nodeMap[nodeId]) {
// Find the correct parent node in the map using parentPath
parentNode = nodeMap[parentPath] || treeRoot;
const newNode = { const newNode = {
children: {}, children: {},
element: null, // Assign element later element: null,
name: nodeKey, // Display name (sliceName or path part) name: nodeKey,
path: path, // Store the full original path from element path: currentPath
id: id || nodeId // Store the element id or constructed path id
}; };
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.').replace(/\[\d+\]/g, '').replace('[x]', '');
// Adjust parentPath for slices using id
if (id && id.includes(':')) {
const idParts = id.split('.');
const sliceIndex = idParts.findIndex(p => p.includes(':'));
if (sliceIndex >= 0 && sliceIndex < i) {
const sliceIdPrefix = idParts.slice(0, sliceIndex + 1).join('.');
const sliceNode = elements.find(e => e.id === sliceIdPrefix);
if (sliceNode && sliceNode.sliceName) {
parentPath = sliceIdPrefix;
}
}
}
parentNode = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}, parentPath=${parentPath}`);
// Ensure parent exists before adding child // Add modifierExtension for DomainResource, BackboneElement, or explicitly defined
if (parentNode) { if (el.path === cleanPart + '.modifierExtension' ||
parentNode.children[nodeKey] = newNode; // Add to parent's children using display name/key (el.type && el.type.some(t => t.code === 'DomainResource' || t.code === 'BackboneElement'))) {
nodeMap[nodeId] = newNode; // Add to map using unique nodeId const modExtPath = `${currentPath}.modifierExtension`;
// console.log(`Created node: nodeId=${nodeId}, name=${nodeKey}, parentPath=${parentPath}`); // DEBUG const modExtNode = {
} else { children: {},
console.warn(`Parent node not found for path: ${parentPath} when creating ${nodeId}`); element: {
path: modExtPath,
id: modExtPath,
short: 'Extensions that cannot be ignored',
definition: 'May be used to represent additional information that modifies the understanding of the element.',
min: 0,
max: '*',
type: [{ code: 'Extension' }],
isModifier: true
},
name: 'modifierExtension',
path: modExtPath
};
newNode.children['modifierExtension'] = modExtNode;
nodeMap[modExtPath] = modExtNode;
console.log(`Added modifierExtension: path=${modExtPath}`);
} }
} }
// Update parentPath for the next iteration
parentPath = nodeId;
// If this is the last part of the path, assign the element data to the node
if (i === parts.length - 1) { if (i === parts.length - 1) {
const targetNode = nodeMap[nodeId]; const targetNode = nodeMap[currentPath];
if (targetNode) { targetNode.element = el;
targetNode.element = el; // Assign the full element data console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
// console.log(`Assigned element to node: nodeId=${nodeId}, id=${el.id}, sliceName=${sliceName}`); // DEBUG
// Handle choice elements ([x]) - This part might need refinement based on how choices are defined // Handle choice elements ([x])
// Usually, the StructureDefinition only has one entry for onset[x] if (isChoiceElement && el.type && el.type.length > 1) {
// and the types are listed within that element. Rendering specific choice options el.type.forEach(type => {
// might be better handled directly in renderNodeAsLi based on el.type. if (type.code === 'dateTime') return; // Skip dateTime as per FHIR convention
} else { const typeName = `onset${type.code.charAt(0).toUpperCase() + type.code.slice(1)}`;
console.warn(`Target node not found in map for assignment: nodeId=${nodeId}`); const typePath = `${currentPath}.${typeName}`;
const typeNode = {
children: {},
element: {
path: typePath,
id: typePath,
short: el.short || typeName,
definition: el.definition || `Choice of type ${type.code}`,
min: el.min,
max: el.max,
type: [{ code: type.code }],
mustSupport: el.mustSupport || false
},
name: typeName,
path: typePath
};
targetNode.children[typeName] = typeNode;
nodeMap[typePath] = typeNode;
console.log(`Added choice type node: path=${typePath}, name=${typeName}`);
});
} }
} }
parentNode = nodeMap[currentPath];
} }
}); });
// Filter out any potential root-level nodes that weren't properly parented (shouldn't happen ideally)
const treeData = Object.values(treeRoot.children); const treeData = Object.values(treeRoot.children);
console.log("Tree data constructed:", treeData.length); console.log("Tree data constructed:", treeData);
return treeData; return treeData;
} }
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) { function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
// Check if node or node.element is null/undefined if (!node || !node.element) {
if (!node || !node.element) { console.warn("Skipping render for invalid node:", node);
// If node exists but element doesn't, maybe it's a structural node without direct element data? return '';
// This can happen if parent nodes were created but no element exactly matched that path. }
// We might want to render *something* or skip it. Let's skip for now.
console.warn("Skipping render for node without element data:", node ? node.name : 'undefined node');
return '';
}
const el = node.element; const el = node.element;
const path = el.path || 'N/A'; // Use path from element const path = el.path || 'N/A';
const id = el.id || node.id || path; // Use element id, then node id, fallback to path const id = el.id || null;
const displayName = node.name || path.split('.').pop(); // Use node name (sliceName or part), fallback to last path part
const sliceName = el.sliceName || null; const sliceName = el.sliceName || null;
const min = el.min !== undefined ? el.min : ''; const min = el.min !== undefined ? el.min : '';
const max = el.max || ''; const max = el.max || '';
const short = el.short || ''; const short = el.short || '';
const definition = el.definition || ''; const definition = el.definition || '';
// console.log(`Rendering node: path=${path}, id=${id}, displayName=${displayName}, sliceName=${sliceName}`); // DEBUG console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
// Check must support against element.path and element.id
let isMustSupport = mustSupportPathsSet.has(path) || (el.id && mustSupportPathsSet.has(el.id));
let isOptional = min == 0 && isMustSupport && el.type && el.type.some(t => t.code === 'Extension'); // Simplified optional check
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
let isOptional = min == 0 && isMustSupport && el.type && el.type.some(t => t.code === 'Extension') && path.includes('extension');
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2'; const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${isOptional ? 'Optional Must Support Extension' : 'Must Support'}"></i>` : ''; const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${isOptional ? 'Optional Must Support Extension' : 'Must Support'}"></i>` : '';
const hasChildren = Object.keys(node.children).length > 0; const hasChildren = Object.keys(node.children).length > 0;
const collapseId = `collapse-${id.replace(/[\.\:\/\[\]\(\)\+\*]/g, '-')}`; // Use unique id for collapse target const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20; const padding = level * 20;
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`; const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
@ -609,7 +619,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (profiles.length > 0) { if (profiles.length > 0) {
const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', '); const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', ');
if (targetTypes) { if (targetTypes) {
s += `(<span class="text-muted fst-italic" title="${profiles.join('\\n')}">${targetTypes}</span>)`; // Use \n for tooltip line breaks s += `(<span class="text-muted fst-italic" title="${profiles.join(', ')}">${targetTypes}</span>)`;
} }
} }
return s; return s;
@ -619,7 +629,6 @@ document.addEventListener('DOMContentLoaded', function() {
let childrenHtml = ''; let childrenHtml = '';
if (hasChildren) { if (hasChildren) {
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`; childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
// Sort children by their element's path for consistent order
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => { Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1); childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1);
}); });
@ -627,44 +636,37 @@ document.addEventListener('DOMContentLoaded', function() {
} }
let itemHtml = `<li class="${liClass}">`; let itemHtml = `<li class="${liClass}">`;
// Add data-bs-parent to potentially make collapses smoother if nested within another collapse itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-collapse-id="${collapseId}" role="button" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`; itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`; itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
if (hasChildren) { if (hasChildren) {
// Use Bootstrap's default +/- or chevron icons based on collapsed state itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`; // Default closed state
} }
itemHtml += `</span>`; itemHtml += `</span>`;
itemHtml += `<code class="fw-bold ms-1" title="${path}">${displayName}</code>`; // Use displayName itemHtml += `<code class="fw-bold ms-1" title="${path}">${node.name}</code>`;
itemHtml += `</div>`; itemHtml += `</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`; itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`; itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`; itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
let descriptionTooltipAttrs = ''; let descriptionTooltipAttrs = '';
if (definition || short) { // Show tooltip if either exists if (definition) {
const tooltipContent = definition ? definition : short; // Prefer definition for tooltip const escapedDefinition = definition
// Basic escaping for attributes .replace(/&/g, '&')
const escapedContent = tooltipContent .replace(/</g, '<')
.replace(/&/g, '&amp;') .replace(/>/g, '>')
.replace(/</g, '&lt;') .replace(/"/g, '"')
.replace(/>/g, '&gt;') .replace(/'/g, '\'')
.replace(/"/g, '&quot;') .replace(/\n/g, ' ');
.replace(/'/g, '&#39;') descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
.replace(/\n/g, ' '); // Replace newlines for attribute
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedContent}"`;
} }
// Display short description, fallback to indication if only definition exists itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition only)' : '')}</div>`;
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition available)' : '')}</div>`; itemHtml += `</div>`;
itemHtml += `</div>`; // End row div itemHtml += childrenHtml;
itemHtml += childrenHtml; // Add children UL (if any) itemHtml += `</li>`;
itemHtml += `</li>`; // End li
return itemHtml; return itemHtml;
} }
function renderStructureTree(elements, mustSupportPaths, resourceType) {
function renderStructureTree(elements, mustSupportPaths, resourceType) {
if (!elements || elements.length === 0) { if (!elements || elements.length === 0) {
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found in Structure Definition.</em></p>'; structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found in Structure Definition.</em></p>';
return; return;
@ -673,84 +675,65 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
const mustSupportPathsSet = new Set(mustSupportPaths); const mustSupportPathsSet = new Set(mustSupportPaths);
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet); console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
let html = '<p class="mb-1"><small>' + let html = '<p><small>' +
'<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (Yellow Row)<br>' + '<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (yellow row)<br>' +
'<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional Must Support Extension (Blue Icon)</small></p>'; '<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional Must Support Extension (blue icon)</small></p>';
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path / Name</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">Status</div><div class="col-lg-3 col-md-4">Description</div></div>`; html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">Status</div><div class="col-lg-3 col-md-4">Description</div></div>`;
html += '<ul class="list-group list-group-flush structure-tree-root">'; // Root UL html += '<ul class="list-group list-group-flush structure-tree-root">';
const treeData = buildTreeData(elements); const treeData = buildTreeData(elements);
// Sort root nodes by path treeData.sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name));
treeData.sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name));
treeData.forEach(rootNode => { treeData.forEach(rootNode => {
html += renderNodeAsLi(rootNode, mustSupportPathsSet, resourceType, 0); html += renderNodeAsLi(rootNode, mustSupportPathsSet, resourceType, 0);
}); });
html += '</ul>'; // Close root UL html += '</ul>';
structureDisplay.innerHTML = html; structureDisplay.innerHTML = html;
// Re-initialize tooltips for the newly added content
const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]')); const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(tooltipTriggerEl => { tooltipTriggerList.forEach(tooltipTriggerEl => {
// Check if title is not empty before initializing
if (tooltipTriggerEl.getAttribute('title')) { if (tooltipTriggerEl.getAttribute('title')) {
return new bootstrap.Tooltip(tooltipTriggerEl); new bootstrap.Tooltip(tooltipTriggerEl);
} }
return null;
}); });
// --- START: Restore Collapse Handling --- // Initialize collapse with manual click handling and debouncing
let lastClickTime = 0; let lastClickTime = 0;
const debounceDelay = 200; // ms const debounceDelay = 200; // ms
// Select the elements that *trigger* the collapse (the div row)
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => { structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
// Find the actual collapsable element (the <ul>) using the ID derived from the toggle's attribute const collapseId = toggleEl.getAttribute('data-collapse-id');
const collapseId = toggleEl.getAttribute('data-bs-target')?.substring(1); // Get ID like 'collapse-Observation-status'
if (!collapseId) { if (!collapseId) {
// Fallback if data-bs-target wasn't used, try data-collapse-id if you used that previously console.warn("No collapse ID for toggle:", toggleEl.outerHTML);
const fallbackId = toggleEl.getAttribute('data-collapse-id'); return;
if(fallbackId) {
collapseId = fallbackId;
} else {
console.warn("No collapse target ID found for toggle:", toggleEl);
return;
}
} }
const collapseEl = document.querySelector(`#${collapseId}`);
const collapseEl = document.getElementById(collapseId);
if (!collapseEl) { if (!collapseEl) {
console.warn("No collapse element found for ID:", collapseId); console.warn("No collapse element found for ID:", collapseId);
return; return;
} }
const toggleIcon = toggleEl.querySelector('.toggle-icon'); const toggleIcon = toggleEl.querySelector('.toggle-icon');
if (!toggleIcon) { if (!toggleIcon) {
console.warn("No toggle icon found for:", collapseId); console.warn("No toggle icon for:", collapseId);
return; return;
} }
console.log("Initializing collapse for trigger:", toggleEl, "target:", collapseId); console.log("Initializing collapse for:", collapseId);
// Initialize Bootstrap Collapse
// Initialize Bootstrap Collapse instance, but don't automatically toggle via attributes
const bsCollapse = new bootstrap.Collapse(collapseEl, { const bsCollapse = new bootstrap.Collapse(collapseEl, {
toggle: false // IMPORTANT: Prevent auto-toggling via data attributes toggle: false
}); });
// Custom click handler with debounce // Custom click handler with debounce
toggleEl.addEventListener('click', event => { toggleEl.addEventListener('click', event => {
event.preventDefault(); // Prevent default action (like navigating if it was an <a>) event.preventDefault();
event.stopPropagation(); // Stop the event from bubbling up event.stopPropagation();
const now = Date.now(); const now = Date.now();
if (now - lastClickTime < debounceDelay) { if (now - lastClickTime < debounceDelay) {
console.log("Debounced click ignored for:", collapseId); console.log("Debounced click ignored for:", collapseId);
return; return;
} }
lastClickTime = now; lastClickTime = now;
console.log("Click toggle for:", collapseId, "Current show:", collapseEl.classList.contains('show')); console.log("Click toggle for:", collapseId, "Current show:", collapseEl.classList.contains('show'));
// Manually toggle the collapse state
if (collapseEl.classList.contains('show')) { if (collapseEl.classList.contains('show')) {
bsCollapse.hide(); bsCollapse.hide();
} else { } else {
@ -758,31 +741,29 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
} }
}); });
// Update icon based on Bootstrap events (these should still work) // Update icon and log state
collapseEl.addEventListener('show.bs.collapse', () => { collapseEl.addEventListener('show.bs.collapse', event => {
console.log("Show event for:", collapseId); console.log("Show collapse triggered for:", event.target.id, "Show class:", event.target.classList.contains('show'));
toggleIcon.classList.remove('bi-chevron-right'); if (toggleIcon) {
toggleIcon.classList.add('bi-chevron-down'); toggleIcon.classList.remove('bi-chevron-right');
toggleIcon.classList.add('bi-chevron-down');
}
}); });
collapseEl.addEventListener('hide.bs.collapse', () => { collapseEl.addEventListener('hide.bs.collapse', event => {
console.log("Hide event for:", collapseId); console.log("Hide collapse triggered for:", event.target.id, "Show class:", event.target.classList.contains('show'));
toggleIcon.classList.remove('bi-chevron-down'); if (toggleIcon) {
toggleIcon.classList.add('bi-chevron-right'); toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
}
});
collapseEl.addEventListener('shown.bs.collapse', event => {
console.log("Shown collapse completed for:", event.target.id, "Show class:", event.target.classList.contains('show'));
});
collapseEl.addEventListener('hidden.bs.collapse', event => {
console.log("Hidden collapse completed for:", event.target.id, "Show class:", event.target.classList.contains('show'));
}); });
// Set initial icon state
if (collapseEl.classList.contains('show')) {
toggleIcon.classList.remove('bi-chevron-right');
toggleIcon.classList.add('bi-chevron-down');
} else {
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
}
}); });
// --- END: Restore Collapse Handling ---
} }
// Catch block for overall DOMContentLoaded function
} catch (e) { } catch (e) {
console.error("JavaScript error in cp_view_processed_ig.html:", e); console.error("JavaScript error in cp_view_processed_ig.html:", e);
const body = document.querySelector('body'); const body = document.querySelector('body');
@ -790,12 +771,7 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
const errorDiv = document.createElement('div'); const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger m-3'; errorDiv.className = 'alert alert-danger m-3';
errorDiv.textContent = 'A critical error occurred while initializing the page view. Please check the console for details.'; errorDiv.textContent = 'A critical error occurred while initializing the page view. Please check the console for details.';
// Avoid inserting before potentially null firstChild body.insertBefore(errorDiv, body.firstChild);
if (body.firstChild) {
body.insertBefore(errorDiv, body.firstChild);
} else {
body.appendChild(errorDiv);
}
} }
} }
}); });

View File

@ -168,6 +168,7 @@ document.addEventListener('DOMContentLoaded', function() {
const packageName = packageNameInput.value; const packageName = packageNameInput.value;
const version = versionInput.value; const version = versionInput.value;
const sampleData = sampleInput.value.trim(); const sampleData = sampleInput.value.trim();
const includeDependencies = document.getElementById('{{ form.include_dependencies.id }}').checked;
if (!packageName || !version) { if (!packageName || !version) {
validationResult.style.display = 'block'; validationResult.style.display = 'block';
@ -195,6 +196,7 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('Sending request to /api/validate-sample with:', { console.log('Sending request to /api/validate-sample with:', {
package_name: packageName, package_name: packageName,
version: version, version: version,
include_dependencies: includeDependencies,
sample_data_length: sampleData.length sample_data_length: sampleData.length
}); });
@ -207,6 +209,7 @@ document.addEventListener('DOMContentLoaded', function() {
body: JSON.stringify({ body: JSON.stringify({
package_name: packageName, package_name: packageName,
version: version, version: version,
include_dependencies: includeDependencies,
sample_data: sampleData sample_data: sampleData
}) })
}) })
@ -229,7 +232,9 @@ document.addEventListener('DOMContentLoaded', function() {
hasContent = true; hasContent = true;
const errorDiv = document.createElement('div'); const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger'; errorDiv.className = 'alert alert-danger';
errorDiv.innerHTML = '<h5>Errors</h5><ul>' + data.errors.map(e => `<li>${e}</li>`).join('') + '</ul>'; errorDiv.innerHTML = '<h5>Errors</h5><ul>' +
data.errors.map(e => `<li>${typeof e === 'string' ? e : e.message}</li>`).join('') +
'</ul>';
validationContent.appendChild(errorDiv); validationContent.appendChild(errorDiv);
} }
@ -237,7 +242,9 @@ document.addEventListener('DOMContentLoaded', function() {
hasContent = true; hasContent = true;
const warningDiv = document.createElement('div'); const warningDiv = document.createElement('div');
warningDiv.className = 'alert alert-warning'; warningDiv.className = 'alert alert-warning';
warningDiv.innerHTML = '<h5>Warnings</h5><ul>' + data.warnings.map(w => `<li>${w}</li>`).join('') + '</ul>'; warningDiv.innerHTML = '<h5>Warnings</h5><ul>' +
data.warnings.map(w => `<li>${typeof w === 'string' ? w : w.message}</li>`).join('') +
'</ul>';
validationContent.appendChild(warningDiv); validationContent.appendChild(warningDiv);
} }
@ -252,11 +259,13 @@ document.addEventListener('DOMContentLoaded', function() {
`; `;
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
resultsDiv.innerHTML += '<ul class="text-danger">' + resultsDiv.innerHTML += '<ul class="text-danger">' +
result.errors.map(e => `<li>${e}</li>`).join('') + '</ul>'; result.errors.map(e => `<li>${typeof e === 'string' ? e : e.message}</li>`).join('') +
'</ul>';
} }
if (result.warnings && result.warnings.length > 0) { if (result.warnings && result.warnings.length > 0) {
resultsDiv.innerHTML += '<ul class="text-warning">' + resultsDiv.innerHTML += '<ul class="text-warning">' +
result.warnings.map(w => `<li>${w}</li>`).join('') + '</ul>'; result.warnings.map(w => `<li>${typeof w === 'string' ? w : w.message}</li>`).join('') +
'</ul>';
} }
} }
validationContent.appendChild(resultsDiv); validationContent.appendChild(resultsDiv);
@ -265,6 +274,9 @@ document.addEventListener('DOMContentLoaded', function() {
const summaryDiv = document.createElement('div'); const summaryDiv = document.createElement('div');
summaryDiv.className = 'alert alert-info'; summaryDiv.className = 'alert alert-info';
summaryDiv.innerHTML = `<h5>Summary</h5><p>Valid: ${data.valid ? 'Yes' : 'No'}</p>`; summaryDiv.innerHTML = `<h5>Summary</h5><p>Valid: ${data.valid ? 'Yes' : 'No'}</p>`;
if (data.summary && (data.summary.error_count || data.summary.warning_count)) {
summaryDiv.innerHTML += `<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>`;
}
validationContent.appendChild(summaryDiv); validationContent.appendChild(summaryDiv);
if (!hasContent && data.valid) { if (!hasContent && data.valid) {