mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-15 13:09:59 +00:00
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:
parent
df3ffadbbb
commit
f38b6aedba
122
app.py
122
app.py
@ -614,7 +614,9 @@ def api_push_ig():
|
||||
package_name = data.get('package_name')
|
||||
version = data.get('version')
|
||||
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]):
|
||||
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://'))):
|
||||
@ -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\.\-\+]+$', version)):
|
||||
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')
|
||||
if not packages_dir:
|
||||
logger.error("[API Push] FHIR_PACKAGES_DIR not configured.")
|
||||
return jsonify({"status": "error", "message": "Server configuration error: Package directory not set."}), 500
|
||||
|
||||
tgz_filename = services.construct_tgz_filename(package_name, version)
|
||||
tgz_path = os.path.join(packages_dir, tgz_filename)
|
||||
if not os.path.exists(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
|
||||
|
||||
# --- Streaming Response ---
|
||||
def generate_stream():
|
||||
pushed_packages_info = []
|
||||
success_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
|
||||
processed_resources = set()
|
||||
failed_uploads_details = [] # <<<<< Initialize list for failure details
|
||||
|
||||
try:
|
||||
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)]
|
||||
dependencies_to_include = []
|
||||
|
||||
# --- Dependency Handling ---
|
||||
if include_dependencies:
|
||||
yield json.dumps({"type": "progress", "message": "Checking dependencies..."}) + "\n"
|
||||
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_path = os.path.join(packages_dir, dep_tgz_filename)
|
||||
if os.path.exists(dep_tgz_path):
|
||||
# Add dependency package to the list of packages to process
|
||||
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:
|
||||
yield json.dumps({"type": "warning", "message": f"Dependency package file not found, skipping: {dep_name}#{dep_version} ({dep_tgz_filename})"}) + "\n"
|
||||
else:
|
||||
yield json.dumps({"type": "info", "message": "No dependency metadata found or no dependencies listed."}) + "\n"
|
||||
|
||||
# --- Resource Extraction ---
|
||||
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:
|
||||
yield json.dumps({"type": "progress", "message": f"Extracting resources from {pkg_name}#{pkg_version}..."}) + "\n"
|
||||
try:
|
||||
@ -673,13 +688,16 @@ def api_push_ig():
|
||||
if (member.isfile() and
|
||||
member.name.startswith('package/') 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']):
|
||||
if member.name in seen_resource_files:
|
||||
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: # Skip if already seen from another package (e.g., core resource in multiple IGs)
|
||||
continue
|
||||
seen_resource_files.add(member.name)
|
||||
|
||||
try:
|
||||
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:
|
||||
resources_to_upload.append({
|
||||
"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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
failure_count += 1
|
||||
continue
|
||||
# Error reading the package itself
|
||||
error_msg = f"Error reading package {pkg_name}#{pkg_version}: {tar_e}. Skipping its resources."
|
||||
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)
|
||||
yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload."}) + "\n"
|
||||
session = requests.Session()
|
||||
yield json.dumps({"type": "info", "message": f"Found {total_resources_attempted} potential resources to upload across all packages."}) + "\n"
|
||||
|
||||
# --- Resource Upload Loop ---
|
||||
session = requests.Session() # Use a session for potential connection reuse
|
||||
headers = {'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'}
|
||||
|
||||
for i, resource_info in enumerate(resources_to_upload, 1):
|
||||
resource = resource_info["data"]
|
||||
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_id = resource.get('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:
|
||||
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
|
||||
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"
|
||||
|
||||
try:
|
||||
# Attempt to PUT the resource
|
||||
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"
|
||||
success_count += 1
|
||||
|
||||
# Track which packages contributed successful uploads
|
||||
if source_pkg not in [p["id"] for p in pushed_packages_info]:
|
||||
pushed_packages_info.append({"id": source_pkg, "resource_count": 1})
|
||||
else:
|
||||
@ -725,68 +758,88 @@ def api_push_ig():
|
||||
if p["id"] == source_pkg:
|
||||
p["resource_count"] += 1
|
||||
break
|
||||
# --- Error Handling for Upload ---
|
||||
except requests.exceptions.Timeout:
|
||||
error_msg = f"Timeout uploading {resource_log_id} to {resource_url}"
|
||||
yield json.dumps({"type": "error", "message": error_msg}) + "\n"
|
||||
failure_count += 1
|
||||
failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
error_msg = f"Connection error uploading {resource_log_id}: {e}"
|
||||
yield json.dumps({"type": "error", "message": error_msg}) + "\n"
|
||||
failure_count += 1
|
||||
failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
|
||||
except requests.exceptions.HTTPError as e:
|
||||
outcome_text = ""
|
||||
# Try to parse OperationOutcome from response
|
||||
try:
|
||||
outcome = e.response.json()
|
||||
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']])
|
||||
except:
|
||||
outcome_text = e.response.text[:200]
|
||||
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 ValueError: # Handle cases where response is not JSON
|
||||
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)}"
|
||||
yield json.dumps({"type": "error", "message": error_msg}) + "\n"
|
||||
failure_count += 1
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Failed to upload {resource_log_id}: {str(e)}"
|
||||
failed_uploads_details.append({'resource': resource_log_id, 'error': error_msg}) # <<<<< ADDED
|
||||
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"
|
||||
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)}"
|
||||
yield json.dumps({"type": "error", "message": error_msg}) + "\n"
|
||||
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 \
|
||||
"partial" if success_count > 0 else \
|
||||
"failure"
|
||||
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)."
|
||||
|
||||
# Create final summary payload
|
||||
summary = {
|
||||
"status": final_status,
|
||||
"message": summary_message,
|
||||
"target_server": fhir_server_url,
|
||||
"package_name": package_name,
|
||||
"version": version,
|
||||
"package_name": package_name, # Initial requested package
|
||||
"version": version, # Initial requested version
|
||||
"included_dependencies": include_dependencies,
|
||||
"resources_attempted": total_resources_attempted,
|
||||
"success_count": success_count,
|
||||
"failure_count": 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"
|
||||
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 = {
|
||||
"status": "error",
|
||||
"message": f"Server error during push operation: {str(e)}"
|
||||
# Optionally add failed_details collected so far if needed
|
||||
# "failed_details": failed_uploads_details
|
||||
}
|
||||
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": "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.")
|
||||
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}")
|
||||
|
||||
# Return the streaming response
|
||||
return Response(generate_stream(), mimetype='application/x-ndjson')
|
||||
|
||||
@app.route('/validate-sample', methods=['GET'])
|
||||
@ -818,6 +871,13 @@ def validate_sample():
|
||||
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():
|
||||
logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||
try:
|
||||
|
Binary file not shown.
@ -17,5 +17,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.760501+00:00"
|
||||
}
|
@ -29,5 +29,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:09.766234+00:00"
|
||||
}
|
@ -4,5 +4,6 @@
|
||||
"dependency_mode": "recursive",
|
||||
"imported_dependencies": [],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.077657+00:00"
|
||||
}
|
@ -9,5 +9,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.693803+00:00"
|
||||
}
|
@ -17,5 +17,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.802489+00:00"
|
||||
}
|
@ -9,5 +9,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:12.239784+00:00"
|
||||
}
|
@ -13,5 +13,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.776036+00:00"
|
||||
}
|
@ -9,5 +9,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:12.227176+00:00"
|
||||
}
|
@ -9,5 +9,6 @@
|
||||
}
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": []
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-14T10:53:11.555556+00:00"
|
||||
}
|
201
services.py
201
services.py
@ -143,7 +143,7 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None):
|
||||
return None, None
|
||||
try:
|
||||
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:
|
||||
if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
|
||||
continue
|
||||
@ -161,23 +161,28 @@ def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None):
|
||||
sd_name = data.get('name')
|
||||
sd_type = data.get('type')
|
||||
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
|
||||
if profile_url and sd_url == profile_url:
|
||||
sd_data = data
|
||||
found_path = member.name
|
||||
logger.info(f"Found SD matching profile '{profile_url}' at path: {found_path}")
|
||||
break
|
||||
# Fallback to resource type or identifier match
|
||||
# Broader matching for resource_identifier
|
||||
elif resource_identifier and (
|
||||
(sd_id and resource_identifier.lower() == sd_id.lower()) or
|
||||
(sd_name and resource_identifier.lower() == sd_name.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
|
||||
found_path = member.name
|
||||
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:
|
||||
logger.debug(f"Could not parse JSON in {member.name}, skipping: {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):
|
||||
"""Validates a FHIR resource against a StructureDefinition in the specified package."""
|
||||
logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}")
|
||||
result = {'valid': True, 'errors': [], 'warnings': []}
|
||||
logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}, include_dependencies={include_dependencies}")
|
||||
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()
|
||||
if not download_dir:
|
||||
result['valid'] = False
|
||||
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")
|
||||
return result
|
||||
|
||||
@ -657,6 +675,11 @@ def validate_resource_against_profile(package_name, version, resource, include_d
|
||||
if not os.path.exists(tgz_path):
|
||||
result['valid'] = False
|
||||
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}")
|
||||
return result
|
||||
|
||||
@ -665,41 +688,85 @@ def validate_resource_against_profile(package_name, version, resource, include_d
|
||||
meta = resource.get('meta', {})
|
||||
profiles = meta.get('profile', [])
|
||||
if profiles:
|
||||
profile_url = profiles[0] # Use first profile
|
||||
profile_url = profiles[0]
|
||||
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)
|
||||
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:
|
||||
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}")
|
||||
return result
|
||||
logger.debug(f"Found SD at {sd_path}")
|
||||
|
||||
# Validate required elements (min=1)
|
||||
errors = []
|
||||
warnings = set() # Deduplicate warnings
|
||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||
for element in elements:
|
||||
path = element.get('path')
|
||||
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('.'):]:
|
||||
value = navigate_fhir_path(resource, path)
|
||||
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")
|
||||
|
||||
# Validate must-support elements
|
||||
warnings = []
|
||||
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])
|
||||
# Check must-support elements
|
||||
if must_support and not '.' in path[1 + path.find('.'):]:
|
||||
if '[x]' in path:
|
||||
base_path = path.replace('[x]', '')
|
||||
found = False
|
||||
# Try common choice type suffixes
|
||||
for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']:
|
||||
test_path = f"{base_path}{suffix}"
|
||||
value = navigate_fhir_path(resource, test_path)
|
||||
@ -707,17 +774,29 @@ def validate_resource_against_profile(package_name, version, resource, include_d
|
||||
found = True
|
||||
break
|
||||
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")
|
||||
else:
|
||||
value = navigate_fhir_path(resource, path)
|
||||
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:
|
||||
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")
|
||||
# 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_found = False
|
||||
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:
|
||||
value = navigate_fhir_path(resource, path)
|
||||
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")
|
||||
|
||||
result['valid'] = len(errors) == 0
|
||||
result['errors'] = errors
|
||||
result['warnings'] = warnings
|
||||
logger.debug(f"Validation result: valid={result['valid']}, errors={result['errors']}, warnings={result['warnings']}")
|
||||
result['warnings'] = list(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
|
||||
|
||||
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
|
||||
"""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 = {
|
||||
'valid': True,
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'results': {}
|
||||
'details': [],
|
||||
'results': {},
|
||||
'summary': {
|
||||
'resource_count': 0,
|
||||
'failed_resources': 0,
|
||||
'profiles_validated': set()
|
||||
}
|
||||
}
|
||||
if not bundle.get('resourceType') == 'Bundle':
|
||||
result['valid'] = False
|
||||
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")
|
||||
return result
|
||||
|
||||
# Track references to validate resolvability
|
||||
references = set()
|
||||
resolved_references = set()
|
||||
|
||||
for entry in bundle.get('entry', []):
|
||||
resource = entry.get('resource')
|
||||
if not resource:
|
||||
continue
|
||||
resource_type = resource.get('resourceType')
|
||||
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)
|
||||
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']:
|
||||
result['valid'] = False
|
||||
result['summary']['failed_resources'] += 1
|
||||
result['errors'].extend(validation_result['errors'])
|
||||
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
|
||||
|
||||
# --- Structure Definition Retrieval ---
|
||||
|
@ -3,7 +3,6 @@
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Left Column: List of Downloaded IGs -->
|
||||
<div class="col-md-6">
|
||||
<h2>Downloaded IGs</h2>
|
||||
{% if packages %}
|
||||
@ -44,7 +43,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Push IGs Form -->
|
||||
<div class="col-md-6">
|
||||
<h2>Push IGs to FHIR Server</h2>
|
||||
<form id="pushIgForm" method="POST">
|
||||
@ -73,7 +71,6 @@
|
||||
<button type="submit" class="btn btn-primary" id="pushButton">Push to FHIR Server</button>
|
||||
</form>
|
||||
|
||||
<!-- Live Console -->
|
||||
<div class="mt-3">
|
||||
<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;">
|
||||
@ -81,7 +78,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Area -->
|
||||
<div id="pushResponse" class="mt-3">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
@ -99,7 +95,11 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const packageSelect = document.getElementById('packageSelect');
|
||||
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
|
||||
packageSelect.addEventListener('change', function() {
|
||||
@ -107,16 +107,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (packageId) {
|
||||
const [packageName, version] = packageId.split('#');
|
||||
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 => {
|
||||
if (data.dependency_mode) {
|
||||
dependencyModeField.value = data.dependency_mode;
|
||||
// Check specifically for the dependency_mode key in the response data
|
||||
if (data && typeof data === 'object' && data.hasOwnProperty('dependency_mode')) {
|
||||
dependencyModeField.value = data.dependency_mode !== null ? data.dependency_mode : 'N/A';
|
||||
} 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 => {
|
||||
console.error('Error fetching metadata:', error);
|
||||
console.error('Error fetching or processing metadata:', error);
|
||||
dependencyModeField.value = 'Error';
|
||||
});
|
||||
} else {
|
||||
@ -129,16 +136,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
packageSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// --- Push IG Form Submission ---
|
||||
document.getElementById('pushIgForm').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
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 [packageName, version] = packageId.split('#');
|
||||
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
|
||||
pushButton.disabled = true;
|
||||
@ -147,55 +165,75 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Clear the console and response area
|
||||
const liveConsole = document.getElementById('liveConsole');
|
||||
const responseDiv = document.getElementById('pushResponse');
|
||||
liveConsole.innerHTML = '<div>Starting push operation...</div>';
|
||||
responseDiv.innerHTML = '';
|
||||
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting push operation for ${packageName}#${version}...</div>`;
|
||||
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 {
|
||||
const response = await fetch('/api/push-ig', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfToken
|
||||
'Accept': 'application/x-ndjson', // We expect NDJSON stream
|
||||
'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,
|
||||
version: version,
|
||||
fhir_server_url: fhirServerUrl,
|
||||
include_dependencies: includeDependencies,
|
||||
api_key: '{{ api_key | safe }}'
|
||||
include_dependencies: includeDependencies
|
||||
// Removed api_key from body as it's now in header
|
||||
})
|
||||
});
|
||||
|
||||
// Handle immediate errors before trying to read the stream
|
||||
if (!response.ok) {
|
||||
let errorMsg = `HTTP error! status: ${response.status}`;
|
||||
try {
|
||||
// Try to parse a JSON error response from the server
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to push package');
|
||||
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 decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (done) break; // Exit loop when stream is finished
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
buffer += decoder.decode(value, { stream: true }); // Decode chunk and append to buffer
|
||||
const lines = buffer.split('\n'); // Split buffer into lines
|
||||
buffer = lines.pop(); // Keep potential partial line for next chunk
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
if (!line.trim()) continue; // Skip empty lines
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const data = JSON.parse(line); // Parse each line as JSON
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = '';
|
||||
let prefix = '';
|
||||
let messageClass = 'text-light'; // Default text color for console
|
||||
let prefix = '[INFO]'; // Default prefix for console
|
||||
|
||||
// --- Switch based on message type from backend ---
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
case 'progress':
|
||||
case 'info':
|
||||
messageClass = 'text-info';
|
||||
prefix = '[INFO]';
|
||||
break;
|
||||
@ -212,58 +250,105 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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]';
|
||||
// ---vvv THIS IS THE CORRECTED BLOCK vvv---
|
||||
// Process the final summary data
|
||||
const summaryData = data.data; // data.data contains the summary object
|
||||
let alertClass = 'alert-info'; // Bootstrap class for responseDiv
|
||||
let statusText = 'Info';
|
||||
let pushedPackagesList = 'None';
|
||||
let failureDetailsHtml = ''; // Initialize failure details HTML
|
||||
|
||||
// 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, "<").replace(/>/g, ">") : "N/A";
|
||||
const escapedError = fail.error ? fail.error.replace(/</g, "<").replace(/>/g, ">") : "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');
|
||||
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;
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing streamed message:', parseError, 'Line:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = '';
|
||||
let prefix = '';
|
||||
let messageClass = 'text-light'; // Default for console
|
||||
let prefix = '[INFO]'; // Default for console
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
case 'start': // Less likely in final buffer, but handle defensively
|
||||
case 'progress':
|
||||
case 'info':
|
||||
messageClass = 'text-info';
|
||||
prefix = '[INFO]';
|
||||
break;
|
||||
@ -279,66 +364,98 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
messageClass = 'text-warning';
|
||||
prefix = '[WARNING]';
|
||||
break;
|
||||
// ---vvv THIS IS THE CORRECTED BLOCK vvv---
|
||||
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>
|
||||
`;
|
||||
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>');
|
||||
}
|
||||
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]';
|
||||
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, "<").replace(/>/g, ">") : "N/A";
|
||||
const escapedError = fail.error ? fail.error.replace(/</g, "<").replace(/>/g, ">") : "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;
|
||||
messageDiv.textContent = `${timestamp} ${prefix} ${data.message || (data.data && data.data.message) || 'Unknown 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;
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing final streamed message:', parseError, 'Buffer:', buffer);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
} 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} [ERROR] Failed to push package: ${error.message}`;
|
||||
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
|
||||
|
||||
} catch (error) { // Handle fetch errors or errors thrown during initial response check
|
||||
console.error("Push operation failed:", error); // Log the error object
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'text-danger';
|
||||
errorDiv.textContent = `${timestamp} [FETCH_ERROR] Failed to push package: ${error.message}`;
|
||||
liveConsole.appendChild(errorDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
|
||||
// Display error message in the response area
|
||||
responseDiv.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> Failed to push package: ${error.message}
|
||||
<strong>Error:</strong> ${error.message}
|
||||
</div>
|
||||
`;
|
||||
} finally {
|
||||
// Re-enable the button regardless of success or failure
|
||||
pushButton.disabled = false;
|
||||
pushButton.textContent = 'Push to FHIR Server';
|
||||
}
|
||||
});
|
||||
});
|
||||
}); // End form submit listener
|
||||
}); // End DOMContentLoaded listener
|
||||
</script>
|
||||
{% endblock %}
|
@ -145,13 +145,15 @@
|
||||
|
||||
<div id="raw-structure-wrapper" class="mt-4" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div>
|
||||
<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">
|
||||
<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 JSON to clipboard</span>
|
||||
<span class="visually-hidden">Copy Raw Definition JSON to clipboard</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
@ -175,21 +177,23 @@
|
||||
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h6>Raw Content</h6>
|
||||
<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">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-raw-content-button"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Content">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Copy JSON to clipboard</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
// Existing element constants...
|
||||
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
|
||||
const structureDisplay = document.getElementById('structure-content');
|
||||
const structureTitle = document.getElementById('structure-title');
|
||||
@ -222,17 +225,81 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const exampleContentWrapper = document.getElementById('example-content-wrapper');
|
||||
const exampleFilename = document.getElementById('example-filename');
|
||||
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 rawStructureTitle = document.getElementById('raw-structure-title');
|
||||
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||
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 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 => {
|
||||
container.addEventListener('click', function(event) {
|
||||
const link = event.target.closest('.resource-type-link');
|
||||
@ -261,40 +328,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
structureFallbackMessage.style.display = 'none';
|
||||
structureDisplayWrapper.style.display = 'block';
|
||||
rawStructureWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
|
||||
fetch(structureFetchUrl)
|
||||
.then(response => {
|
||||
console.log("Structure fetch response status:", response.status);
|
||||
// Use response.json() directly, check ok status in the next .then()
|
||||
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();
|
||||
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
|
||||
})
|
||||
.then(data => { // No longer destructuring result here, just using data
|
||||
console.log("Structure data received:", data);
|
||||
if (data.fallback_used) {
|
||||
.then(result => {
|
||||
if (!result.ok) {
|
||||
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.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 {
|
||||
structureFallbackMessage.style.display = 'none';
|
||||
}
|
||||
renderStructureTree(data.elements, data.must_support_paths || [], resourceType);
|
||||
rawStructureContent.textContent = JSON.stringify(data, null, 2); // Changed result.data to data
|
||||
populateExampleSelector(resourceType); // Show/hide example section based on availability
|
||||
renderStructureTree(result.data.elements, result.data.must_support_paths || [], resourceType);
|
||||
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching/processing structure:", error);
|
||||
console.error("Error fetching structure:", error);
|
||||
structureFallbackMessage.style.display = 'none';
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
|
||||
rawStructureContent.textContent = `Error loading structure: ${error.message}`;
|
||||
// Still try to populate examples, might be useful even if structure fails? Or hide it?
|
||||
// Let's hide it if structure fails completely
|
||||
exampleDisplayWrapper.style.display = 'none';
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.finally(() => {
|
||||
structureLoading.style.display = 'none';
|
||||
@ -303,7 +364,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Existing function populateExampleSelector...
|
||||
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||
console.log("Populating examples for:", resourceOrProfileIdentifier);
|
||||
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||
@ -324,14 +384,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
exampleSelect.appendChild(option);
|
||||
});
|
||||
exampleSelectorWrapper.style.display = 'block';
|
||||
exampleDisplayWrapper.style.display = 'block'; // Show the whole example section
|
||||
exampleDisplayWrapper.style.display = 'block';
|
||||
} else {
|
||||
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) {
|
||||
exampleSelect.addEventListener('change', function(event) {
|
||||
const selectedFilePath = this.value;
|
||||
@ -341,6 +400,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
exampleFilename.textContent = '';
|
||||
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;
|
||||
|
||||
const exampleParams = new URLSearchParams({
|
||||
@ -386,94 +454,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- START: Add Copy Button Logic ---
|
||||
const copyJsonButton = document.getElementById('copy-json-button');
|
||||
// const jsonCodeElement = document.getElementById('example-content-json'); // Already defined above
|
||||
let copyTooltipInstance = null; // To hold the tooltip object
|
||||
// --- ADDED: Setup Copy Buttons Calls---
|
||||
copyRawDefTooltipInstance = setupCopyButton(copyRawDefButton, rawStructureContent, copyRawDefTooltipInstance, 'Copied Definition!', 'Copy Raw Definition JSON', 'Copy Failed!', 'Nothing to copy');
|
||||
copyRawExTooltipInstance = setupCopyButton(copyRawExButton, exampleContentRaw, copyRawExTooltipInstance, 'Copied Raw!', 'Copy Raw Content', 'Copy Failed!', 'Nothing to copy');
|
||||
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) {
|
||||
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||
const nodeMap = { 'Root': treeRoot };
|
||||
@ -484,7 +470,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const id = el.id || 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) {
|
||||
console.warn(`Skipping element ${index} with no path`);
|
||||
@ -494,110 +480,134 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const parts = path.split('.');
|
||||
let currentPath = '';
|
||||
let parentNode = treeRoot;
|
||||
let parentPath = 'Root'; // Keep track of the parent's logical path
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let part = parts[i];
|
||||
const isChoiceElement = part.endsWith('[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
|
||||
let nodeId = id ? id : currentPathSegment; // Use element.id if available for map key
|
||||
|
||||
// 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) {
|
||||
// Handle slices using id for accurate parent lookup
|
||||
if (sliceName && i === parts.length - 1) {
|
||||
nodeKey = sliceName;
|
||||
nodeId = id; // Use the full ID like "Observation.component:systolic.value[x]"
|
||||
} 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'
|
||||
currentPath = id || currentPath;
|
||||
}
|
||||
|
||||
|
||||
if (!nodeMap[nodeId]) {
|
||||
// Find the correct parent node in the map using parentPath
|
||||
parentNode = nodeMap[parentPath] || treeRoot;
|
||||
|
||||
if (!nodeMap[currentPath]) {
|
||||
const newNode = {
|
||||
children: {},
|
||||
element: null, // Assign element later
|
||||
name: nodeKey, // Display name (sliceName or path part)
|
||||
path: path, // Store the full original path from element
|
||||
id: id || nodeId // Store the element id or constructed path id
|
||||
element: null,
|
||||
name: nodeKey,
|
||||
path: currentPath
|
||||
};
|
||||
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
|
||||
if (parentNode) {
|
||||
parentNode.children[nodeKey] = newNode; // Add to parent's children using display name/key
|
||||
nodeMap[nodeId] = newNode; // Add to map using unique nodeId
|
||||
// console.log(`Created node: nodeId=${nodeId}, name=${nodeKey}, parentPath=${parentPath}`); // DEBUG
|
||||
} else {
|
||||
console.warn(`Parent node not found for path: ${parentPath} when creating ${nodeId}`);
|
||||
// Add modifierExtension for DomainResource, BackboneElement, or explicitly defined
|
||||
if (el.path === cleanPart + '.modifierExtension' ||
|
||||
(el.type && el.type.some(t => t.code === 'DomainResource' || t.code === 'BackboneElement'))) {
|
||||
const modExtPath = `${currentPath}.modifierExtension`;
|
||||
const modExtNode = {
|
||||
children: {},
|
||||
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) {
|
||||
const targetNode = nodeMap[nodeId];
|
||||
if (targetNode) {
|
||||
targetNode.element = el; // Assign the full element data
|
||||
// console.log(`Assigned element to node: nodeId=${nodeId}, id=${el.id}, sliceName=${sliceName}`); // DEBUG
|
||||
const targetNode = nodeMap[currentPath];
|
||||
targetNode.element = el;
|
||||
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
|
||||
|
||||
// Handle choice elements ([x]) - This part might need refinement based on how choices are defined
|
||||
// Usually, the StructureDefinition only has one entry for onset[x]
|
||||
// and the types are listed within that element. Rendering specific choice options
|
||||
// might be better handled directly in renderNodeAsLi based on el.type.
|
||||
} else {
|
||||
console.warn(`Target node not found in map for assignment: nodeId=${nodeId}`);
|
||||
// Handle choice elements ([x])
|
||||
if (isChoiceElement && el.type && el.type.length > 1) {
|
||||
el.type.forEach(type => {
|
||||
if (type.code === 'dateTime') return; // Skip dateTime as per FHIR convention
|
||||
const typeName = `onset${type.code.charAt(0).toUpperCase() + type.code.slice(1)}`;
|
||||
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);
|
||||
console.log("Tree data constructed:", treeData.length);
|
||||
console.log("Tree data constructed:", treeData);
|
||||
return treeData;
|
||||
}
|
||||
|
||||
|
||||
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
|
||||
// Check if node or node.element is null/undefined
|
||||
if (!node || !node.element) {
|
||||
// If node exists but element doesn't, maybe it's a structural node without direct element data?
|
||||
// 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');
|
||||
console.warn("Skipping render for invalid node:", node);
|
||||
return '';
|
||||
}
|
||||
|
||||
const el = node.element;
|
||||
const path = el.path || 'N/A'; // Use path from element
|
||||
const id = el.id || node.id || path; // Use element id, then node id, fallback to path
|
||||
const displayName = node.name || path.split('.').pop(); // Use node name (sliceName or part), fallback to last path part
|
||||
const path = el.path || 'N/A';
|
||||
const id = el.id || null;
|
||||
const sliceName = el.sliceName || null;
|
||||
const min = el.min !== undefined ? el.min : '';
|
||||
const max = el.max || '';
|
||||
const short = el.short || '';
|
||||
const definition = el.definition || '';
|
||||
|
||||
// console.log(`Rendering node: path=${path}, id=${id}, displayName=${displayName}, sliceName=${sliceName}`); // DEBUG
|
||||
|
||||
// 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
|
||||
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
|
||||
|
||||
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 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 collapseId = `collapse-${id.replace(/[\.\:\/\[\]\(\)\+\*]/g, '-')}`; // Use unique id for collapse target
|
||||
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
|
||||
const padding = level * 20;
|
||||
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
|
||||
|
||||
@ -609,7 +619,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (profiles.length > 0) {
|
||||
const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', ');
|
||||
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;
|
||||
@ -619,7 +629,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let childrenHtml = '';
|
||||
if (hasChildren) {
|
||||
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 => {
|
||||
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1);
|
||||
});
|
||||
@ -627,43 +636,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
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 ? `role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
|
||||
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="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
|
||||
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
|
||||
if (hasChildren) {
|
||||
// Use Bootstrap's default +/- or chevron icons based on collapsed state
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`; // Default closed state
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
|
||||
}
|
||||
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 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-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
|
||||
|
||||
let descriptionTooltipAttrs = '';
|
||||
if (definition || short) { // Show tooltip if either exists
|
||||
const tooltipContent = definition ? definition : short; // Prefer definition for tooltip
|
||||
// Basic escaping for attributes
|
||||
const escapedContent = tooltipContent
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, ' '); // Replace newlines for attribute
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedContent}"`;
|
||||
if (definition) {
|
||||
const escapedDefinition = definition
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
.replace(/\n/g, ' ');
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
|
||||
}
|
||||
// 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 available)' : '')}</div>`;
|
||||
itemHtml += `</div>`; // End row div
|
||||
itemHtml += childrenHtml; // Add children UL (if any)
|
||||
itemHtml += `</li>`; // End li
|
||||
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition only)' : '')}</div>`;
|
||||
itemHtml += `</div>`;
|
||||
itemHtml += childrenHtml;
|
||||
itemHtml += `</li>`;
|
||||
return itemHtml;
|
||||
}
|
||||
|
||||
|
||||
function renderStructureTree(elements, mustSupportPaths, resourceType) {
|
||||
if (!elements || elements.length === 0) {
|
||||
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found in Structure Definition.</em></p>';
|
||||
@ -673,84 +675,65 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
|
||||
const mustSupportPathsSet = new Set(mustSupportPaths);
|
||||
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
|
||||
|
||||
let html = '<p class="mb-1"><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-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 += '<ul class="list-group list-group-flush structure-tree-root">'; // Root UL
|
||||
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-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</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">';
|
||||
|
||||
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.forEach(rootNode => {
|
||||
html += renderNodeAsLi(rootNode, mustSupportPathsSet, resourceType, 0);
|
||||
});
|
||||
html += '</ul>'; // Close root UL
|
||||
html += '</ul>';
|
||||
|
||||
structureDisplay.innerHTML = html;
|
||||
|
||||
// Re-initialize tooltips for the newly added content
|
||||
const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(tooltipTriggerEl => {
|
||||
// Check if title is not empty before initializing
|
||||
tooltipTriggerList.forEach(tooltipTriggerEl => {
|
||||
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;
|
||||
const debounceDelay = 200; // ms
|
||||
|
||||
// Select the elements that *trigger* the collapse (the div row)
|
||||
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-bs-target')?.substring(1); // Get ID like 'collapse-Observation-status'
|
||||
const collapseId = toggleEl.getAttribute('data-collapse-id');
|
||||
if (!collapseId) {
|
||||
// Fallback if data-bs-target wasn't used, try data-collapse-id if you used that previously
|
||||
const fallbackId = toggleEl.getAttribute('data-collapse-id');
|
||||
if(fallbackId) {
|
||||
collapseId = fallbackId;
|
||||
} else {
|
||||
console.warn("No collapse target ID found for toggle:", toggleEl);
|
||||
console.warn("No collapse ID for toggle:", toggleEl.outerHTML);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const collapseEl = document.getElementById(collapseId);
|
||||
const collapseEl = document.querySelector(`#${collapseId}`);
|
||||
if (!collapseEl) {
|
||||
console.warn("No collapse element found for ID:", collapseId);
|
||||
return;
|
||||
}
|
||||
const toggleIcon = toggleEl.querySelector('.toggle-icon');
|
||||
if (!toggleIcon) {
|
||||
console.warn("No toggle icon found for:", collapseId);
|
||||
console.warn("No toggle icon for:", collapseId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Initializing collapse for trigger:", toggleEl, "target:", collapseId);
|
||||
|
||||
// Initialize Bootstrap Collapse instance, but don't automatically toggle via attributes
|
||||
console.log("Initializing collapse for:", collapseId);
|
||||
// Initialize Bootstrap Collapse
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, {
|
||||
toggle: false // IMPORTANT: Prevent auto-toggling via data attributes
|
||||
toggle: false
|
||||
});
|
||||
|
||||
// Custom click handler with debounce
|
||||
toggleEl.addEventListener('click', event => {
|
||||
event.preventDefault(); // Prevent default action (like navigating if it was an <a>)
|
||||
event.stopPropagation(); // Stop the event from bubbling up
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const now = Date.now();
|
||||
if (now - lastClickTime < debounceDelay) {
|
||||
console.log("Debounced click ignored for:", collapseId);
|
||||
return;
|
||||
}
|
||||
lastClickTime = now;
|
||||
|
||||
console.log("Click toggle for:", collapseId, "Current show:", collapseEl.classList.contains('show'));
|
||||
|
||||
// Manually toggle the collapse state
|
||||
if (collapseEl.classList.contains('show')) {
|
||||
bsCollapse.hide();
|
||||
} else {
|
||||
@ -758,31 +741,29 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
|
||||
}
|
||||
});
|
||||
|
||||
// Update icon based on Bootstrap events (these should still work)
|
||||
collapseEl.addEventListener('show.bs.collapse', () => {
|
||||
console.log("Show event for:", collapseId);
|
||||
// Update icon and log state
|
||||
collapseEl.addEventListener('show.bs.collapse', event => {
|
||||
console.log("Show collapse triggered for:", event.target.id, "Show class:", event.target.classList.contains('show'));
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.remove('bi-chevron-right');
|
||||
toggleIcon.classList.add('bi-chevron-down');
|
||||
}
|
||||
});
|
||||
collapseEl.addEventListener('hide.bs.collapse', () => {
|
||||
console.log("Hide event for:", collapseId);
|
||||
toggleIcon.classList.remove('bi-chevron-down');
|
||||
toggleIcon.classList.add('bi-chevron-right');
|
||||
});
|
||||
// Set initial icon state
|
||||
if (collapseEl.classList.contains('show')) {
|
||||
toggleIcon.classList.remove('bi-chevron-right');
|
||||
toggleIcon.classList.add('bi-chevron-down');
|
||||
} else {
|
||||
collapseEl.addEventListener('hide.bs.collapse', event => {
|
||||
console.log("Hide collapse triggered for:", event.target.id, "Show class:", event.target.classList.contains('show'));
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.remove('bi-chevron-down');
|
||||
toggleIcon.classList.add('bi-chevron-right');
|
||||
}
|
||||
});
|
||||
// --- END: Restore Collapse Handling ---
|
||||
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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Catch block for overall DOMContentLoaded function
|
||||
} catch (e) {
|
||||
console.error("JavaScript error in cp_view_processed_ig.html:", e);
|
||||
const body = document.querySelector('body');
|
||||
@ -790,12 +771,7 @@ function renderStructureTree(elements, mustSupportPaths, resourceType) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger m-3';
|
||||
errorDiv.textContent = 'A critical error occurred while initializing the page view. Please check the console for details.';
|
||||
// Avoid inserting before potentially null firstChild
|
||||
if (body.firstChild) {
|
||||
body.insertBefore(errorDiv, body.firstChild);
|
||||
} else {
|
||||
body.appendChild(errorDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -168,6 +168,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const packageName = packageNameInput.value;
|
||||
const version = versionInput.value;
|
||||
const sampleData = sampleInput.value.trim();
|
||||
const includeDependencies = document.getElementById('{{ form.include_dependencies.id }}').checked;
|
||||
|
||||
if (!packageName || !version) {
|
||||
validationResult.style.display = 'block';
|
||||
@ -195,6 +196,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Sending request to /api/validate-sample with:', {
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data_length: sampleData.length
|
||||
});
|
||||
|
||||
@ -207,6 +209,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
body: JSON.stringify({
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data: sampleData
|
||||
})
|
||||
})
|
||||
@ -229,7 +232,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
hasContent = true;
|
||||
const errorDiv = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
|
||||
@ -237,7 +242,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
hasContent = true;
|
||||
const warningDiv = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
|
||||
@ -252,11 +259,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
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) {
|
||||
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);
|
||||
@ -265,6 +274,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'alert alert-info';
|
||||
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);
|
||||
|
||||
if (!hasContent && data.valid) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user