Merge pull request #2 from Sudo-JHare/Slicing-changes

Slicing changes
This commit is contained in:
Joshua Hare 2025-04-14 11:57:53 +10:00 committed by GitHub
commit 05d23aa268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1109 additions and 421 deletions

167
app.py
View File

@ -20,7 +20,6 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# --- Configuration ---
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-fallback-secret-key-here')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/fhir_ig.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
@ -32,7 +31,6 @@ app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
# Ensure directories exist and are writable
instance_path = '/app/instance'
packages_path = app.config['FHIR_PACKAGES_DIR']
logger.debug(f"Instance path configuration: {instance_path}")
logger.debug(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
logger.debug(f"Packages path: {packages_path}")
@ -49,7 +47,6 @@ except Exception as e:
db = SQLAlchemy(app)
csrf = CSRFProtect(app)
# --- Database Model ---
class ProcessedIg(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_name = db.Column(db.String(128), nullable=False)
@ -61,10 +58,8 @@ class ProcessedIg(db.Model):
complies_with_profiles = db.Column(db.JSON, nullable=True)
imposed_profiles = db.Column(db.JSON, nullable=True)
optional_usage_elements = db.Column(db.JSON, nullable=True)
__table_args__ = (db.UniqueConstraint('package_name', 'version', name='uq_package_version'),)
# --- API Key Middleware ---
def check_api_key():
api_key = request.headers.get('X-API-Key')
if not api_key and request.is_json:
@ -78,47 +73,6 @@ def check_api_key():
logger.debug("API key validated successfully")
return None
# --- Routes ---
@app.route('/')
def index():
return render_template('index.html', site_name='FHIRFLARE IG Toolkit', now=datetime.datetime.now())
@app.route('/import-ig', methods=['GET', 'POST'])
def import_ig():
form = IgImportForm()
if form.validate_on_submit():
name = form.package_name.data
version = form.package_version.data
dependency_mode = form.dependency_mode.data
try:
result = services.import_package_and_dependencies(name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']:
error_msg = result['errors'][0]
simplified_msg = error_msg
if "HTTP error" in error_msg and "404" in error_msg:
simplified_msg = "Package not found on registry (404). Check name and version."
elif "HTTP error" in error_msg:
simplified_msg = f"Registry error: {error_msg.split(': ', 1)[-1]}"
elif "Connection error" in error_msg:
simplified_msg = "Could not connect to the FHIR package registry."
flash(f"Failed to import {name}#{version}: {simplified_msg}", "error")
logger.error(f"Import failed critically for {name}#{version}: {error_msg}")
else:
if result['errors']:
flash(f"Partially imported {name}#{version} with errors during dependency processing. Check logs.", "warning")
for err in result['errors']: logger.warning(f"Import warning for {name}#{version}: {err}")
else:
flash(f"Successfully downloaded {name}#{version} and dependencies! Mode: {dependency_mode}", "success")
return redirect(url_for('view_igs'))
except Exception as e:
logger.error(f"Unexpected error during IG import: {str(e)}", exc_info=True)
flash(f"An unexpected error occurred downloading the IG: {str(e)}", "error")
else:
for field, errors in form.errors.items():
for error in errors:
flash(f"Error in {getattr(form, field).label.text}: {error}", "danger")
return render_template('import_ig.html', form=form, site_name='FHIRFLARE IG Toolkit', now=datetime.datetime.now())
def list_downloaded_packages(packages_dir):
packages = []
errors = []
@ -159,7 +113,6 @@ def list_downloaded_packages(packages_dir):
else:
logger.warning(f"Skipping package {filename} due to invalid name ('{name}') or version ('{version}')")
errors.append(f"Invalid package {filename}: name='{name}', version='{version}'")
# Build duplicate_groups
name_counts = {}
for pkg in packages:
name = pkg['name']
@ -172,6 +125,47 @@ def list_downloaded_packages(packages_dir):
logger.debug(f"Duplicate groups: {duplicate_groups}")
return packages, errors, duplicate_groups
@app.route('/')
def index():
return render_template('index.html', site_name='FHIRFLARE IG Toolkit', now=datetime.datetime.now())
@app.route('/import-ig', methods=['GET', 'POST'])
def import_ig():
form = IgImportForm()
if form.validate_on_submit():
name = form.package_name.data
version = form.package_version.data
dependency_mode = form.dependency_mode.data
try:
result = services.import_package_and_dependencies(name, version, dependency_mode=dependency_mode)
if result['errors'] and not result['downloaded']:
error_msg = result['errors'][0]
simplified_msg = error_msg
if "HTTP error" in error_msg and "404" in error_msg:
simplified_msg = "Package not found on registry (404). Check name and version."
elif "HTTP error" in error_msg:
simplified_msg = f"Registry error: {error_msg.split(': ', 1)[-1]}"
elif "Connection error" in error_msg:
simplified_msg = "Could not connect to the FHIR package registry."
flash(f"Failed to import {name}#{version}: {simplified_msg}", "error")
logger.error(f"Import failed critically for {name}#{version}: {error_msg}")
else:
if result['errors']:
flash(f"Partially imported {name}#{version} with errors during dependency processing. Check logs.", "warning")
for err in result['errors']:
logger.warning(f"Import warning for {name}#{version}: {err}")
else:
flash(f"Successfully downloaded {name}#{version} and dependencies! Mode: {dependency_mode}", "success")
return redirect(url_for('view_igs'))
except Exception as e:
logger.error(f"Unexpected error during IG import: {str(e)}", exc_info=True)
flash(f"An unexpected error occurred downloading the IG: {str(e)}", "error")
else:
for field, errors in form.errors.items():
for error in errors:
flash(f"Error in {getattr(form, field).label.text}: {error}", "danger")
return render_template('import_ig.html', form=form, site_name='FHIRFLARE IG Toolkit', now=datetime.datetime.now())
@app.route('/view-igs')
def view_igs():
form = FlaskForm()
@ -230,6 +224,8 @@ def process_ig():
try:
logger.info(f"Starting processing for {name}#{version} from file {filename}")
package_info = services.process_package_file(tgz_path)
if package_info.get('errors'):
flash(f"Processing completed with errors for {name}#{version}: {', '.join(package_info['errors'])}", "warning")
optional_usage_dict = {
info['name']: True
for info in package_info.get('resource_types_info', [])
@ -302,7 +298,8 @@ def delete_ig():
errors.append(f"Could not delete metadata for {filename}: {e}")
logger.error(f"Error deleting {metadata_path}: {e}")
if errors:
for error in errors: flash(error, "error")
for error in errors:
flash(error, "error")
elif deleted_files:
flash(f"Deleted: {', '.join(deleted_files)}", "success")
else:
@ -356,9 +353,11 @@ def view_ig(processed_ig_id):
base_list = [t for t in processed_ig.resource_types_info if not t.get('is_profile')]
examples_by_type = processed_ig.examples or {}
optional_usage_elements = processed_ig.optional_usage_elements or {}
logger.debug(f"Optional usage elements for {processed_ig.package_name}#{processed_ig.version}: {optional_usage_elements}")
complies_with_profiles = processed_ig.complies_with_profiles or []
imposed_profiles = processed_ig.imposed_profiles or []
logger.debug(f"Viewing IG {processed_ig.package_name}#{processed_ig.version}: "
f"{len(profile_list)} profiles, {len(base_list)} base resources, "
f"{len(optional_usage_elements)} optional elements")
return render_template('cp_view_processed_ig.html',
title=f"View {processed_ig.package_name}#{processed_ig.version}",
processed_ig=processed_ig,
@ -462,6 +461,7 @@ def get_example_content():
package_version = request.args.get('package_version')
example_member_path = request.args.get('filename')
if not all([package_name, package_version, example_member_path]):
logger.warning(f"get_example_content: Missing query parameters: name={package_name}, version={package_version}, path={example_member_path}")
return jsonify({"error": "Missing required query parameters: package_name, package_version, filename"}), 400
if not example_member_path.startswith('package/') or '..' in example_member_path:
logger.warning(f"Invalid example file path requested: {example_member_path}")
@ -649,7 +649,8 @@ def api_push_ig():
for dep in dependencies_to_include:
dep_name = dep.get('name')
dep_version = dep.get('version')
if not dep_name or not dep_version: continue
if not dep_name or not dep_version:
continue
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):
@ -789,83 +790,61 @@ def api_push_ig():
def validate_sample():
form = ValidationForm()
validation_report = None
packages_for_template = []
packages_dir = app.config.get('FHIR_PACKAGES_DIR')
if packages_dir:
packages = []
packages_dir = app.config['FHIR_PACKAGES_DIR']
if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
try:
all_packages, errors, duplicate_groups = list_downloaded_packages(packages_dir)
if errors:
flash(f"Warning: Errors encountered while listing packages: {', '.join(errors)}", "warning")
filtered_packages = [
pkg for pkg in all_packages
if isinstance(pkg.get('name'), str) and pkg.get('name') and
isinstance(pkg.get('version'), str) and pkg.get('version')
]
packages_for_template = sorted([
{"id": f"{pkg['name']}#{pkg['version']}", "text": f"{pkg['name']}#{pkg['version']}"}
for pkg in filtered_packages
], key=lambda x: x['text'])
logger.debug(f"Packages for template: {packages_for_template}")
with tarfile.open(os.path.join(packages_dir, filename), 'r:gz') as tar:
package_json = tar.extractfile('package/package.json')
if package_json:
pkg_info = json.load(package_json)
name = pkg_info.get('name')
version = pkg_info.get('version')
if name and version:
packages.append({'name': name, 'version': version})
except Exception as e:
logger.error(f"Failed to list or process downloaded packages: {e}", exc_info=True)
flash("Error loading available packages.", "danger")
else:
flash("FHIR Packages directory not configured.", "danger")
logger.error("FHIR_PACKAGES_DIR is not configured in the Flask app.")
logger.warning(f"Error reading package {filename}: {e}")
continue
if form.validate_on_submit():
package_name = form.package_name.data
version = form.version.data
include_dependencies = form.include_dependencies.data
mode = form.mode.data
sample_input_raw = form.sample_input.data
try:
sample_input = json.loads(sample_input_raw)
logger.info(f"Starting validation (mode: {mode}) for {package_name}#{version}, deps: {include_dependencies}")
sample_input = json.loads(form.sample_input.data)
if mode == 'single':
validation_report = services.validate_resource_against_profile(
package_name, version, sample_input, include_dependencies
)
elif mode == 'bundle':
else:
validation_report = services.validate_bundle_against_profile(
package_name, version, sample_input, include_dependencies
)
else:
flash("Invalid validation mode selected.", "error")
validation_report = None
if validation_report:
flash("Validation completed.", 'info')
logger.info(f"Validation Result: Valid={validation_report.get('valid')}, Errors={len(validation_report.get('errors',[]))}, Warnings={len(validation_report.get('warnings',[]))}")
flash("Validation completed.", 'success')
except json.JSONDecodeError:
flash("Invalid JSON format in sample input.", 'error')
logger.warning("Validation failed: Invalid JSON input.")
validation_report = {'valid': False, 'errors': ['Invalid JSON format provided.'], 'warnings': [], 'results': {}}
except FileNotFoundError as e:
flash(f"Validation Error: Required package file not found for {package_name}#{version}. Please ensure it's downloaded.", 'error')
logger.error(f"Validation failed: Package file missing - {e}")
validation_report = {'valid': False, 'errors': [f"Required package file not found: {package_name}#{version}"], 'warnings': [], 'results': {}}
except Exception as e:
logger.error(f"Error validating sample: {e}", exc_info=True)
flash(f"An unexpected error occurred during validation: {str(e)}", 'error')
validation_report = {'valid': False, 'errors': [f'Unexpected error: {str(e)}'], 'warnings': [], 'results': {}}
logger.error(f"Error validating sample: {e}")
flash(f"Error validating sample: {str(e)}", 'error')
validation_report = {'valid': False, 'errors': [str(e)], 'warnings': [], 'results': {}}
else:
for field, errors in form.errors.items():
field_obj = getattr(form, field, None)
field_label = field_obj.label.text if field_obj and hasattr(field_obj, 'label') else field
for error in errors:
flash(f"Error in field '{field_label}': {error}", "danger")
return render_template(
'validate_sample.html',
form=form,
packages=packages_for_template,
packages=packages,
validation_report=validation_report,
site_name='FHIRFLARE IG Toolkit',
now=datetime.datetime.now()
)
# --- App Initialization ---
def create_db():
logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
try:

View File

@ -1,18 +1,18 @@
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Regexp, Optional, ValidationError
from wtforms.validators import DataRequired, Regexp, ValidationError
import json
class IgImportForm(FlaskForm):
package_name = StringField('Package Name', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]$', message="Invalid package name format.")
])
], render_kw={'placeholder': 'e.g., hl7.fhir.au.core'})
package_version = StringField('Package Version', validators=[
DataRequired(),
Regexp(r'^[a-zA-Z0-9\.\-]+$', message="Invalid version format. Use alphanumeric characters, dots, or hyphens (e.g., 1.2.3, 1.1.0-preview, current).")
])
], render_kw={'placeholder': 'e.g., 1.1.0-preview'})
dependency_mode = SelectField('Dependency Mode', choices=[
('recursive', 'Current Recursive'),
('patch-canonical', 'Patch Canonical Versions'),

Binary file not shown.

BIN
instance/fhir_ig.db.old Normal file

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"package_name": "hl7.fhir.au.base",
"version": "5.1.0-preview",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "6.2.0"
},
{
"name": "hl7.fhir.uv.extensions.r4",
"version": "5.2.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -1,7 +1,7 @@
{
"package_name": "hl7.fhir.au.core",
"version": "1.1.0-preview",
"dependency_mode": "tree-shaking",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
@ -29,6 +29,5 @@
}
],
"complies_with_profiles": [],
"imposed_profiles": [],
"timestamp": "2025-04-13T13:43:59.139683+00:00"
"imposed_profiles": []
}

View File

@ -1,9 +1,8 @@
{
"package_name": "hl7.fhir.r4.core",
"version": "4.0.1",
"dependency_mode": "tree-shaking",
"dependency_mode": "recursive",
"imported_dependencies": [],
"complies_with_profiles": [],
"imposed_profiles": [],
"timestamp": "2025-04-13T13:44:17.228391+00:00"
"imposed_profiles": []
}

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.fhir.uv.extensions.r4",
"version": "5.2.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,21 @@
{
"package_name": "hl7.fhir.uv.ipa",
"version": "1.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "5.0.0"
},
{
"name": "hl7.fhir.uv.smart-app-launch",
"version": "2.0.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.fhir.uv.smart-app-launch",
"version": "2.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,17 @@
{
"package_name": "hl7.fhir.uv.smart-app-launch",
"version": "2.1.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
},
{
"name": "hl7.terminology.r4",
"version": "5.0.0"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.terminology.r4",
"version": "5.0.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"package_name": "hl7.terminology.r4",
"version": "6.2.0",
"dependency_mode": "recursive",
"imported_dependencies": [
{
"name": "hl7.fhir.r4.core",
"version": "4.0.1"
}
],
"complies_with_profiles": [],
"imposed_profiles": []
}

Binary file not shown.

View File

@ -22,7 +22,7 @@ DOWNLOAD_DIR_NAME = "fhir_packages"
CANONICAL_PACKAGE = ("hl7.fhir.r4.core", "4.0.1")
CANONICAL_PACKAGE_ID = f"{CANONICAL_PACKAGE[0]}#{CANONICAL_PACKAGE[1]}"
# Define standard FHIR R4 base types (used selectively)
# Define standard FHIR R4 base types
FHIR_R4_BASE_TYPES = {
"Account", "ActivityDefinition", "AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance",
"Appointment", "AppointmentResponse", "AuditEvent", "Basic", "Binary", "BiologicallyDerivedProduct",
@ -111,13 +111,7 @@ def construct_metadata_filename(name, version):
return f"{sanitize_filename_part(name)}-{sanitize_filename_part(version)}.metadata.json"
def parse_package_filename(filename):
"""
Parses a standard FHIR package filename (e.g., name-version.tgz)
into package name and version. Handles common variations.
Returns (name, version) tuple, or (None, None) if parsing fails.
"""
name = None
version = None
"""Parses a standard FHIR package filename into name and version."""
if not filename or not filename.endswith('.tgz'):
logger.debug(f"Filename '{filename}' does not end with .tgz")
return None, None
@ -133,20 +127,15 @@ def parse_package_filename(filename):
return name, version
else:
last_hyphen_index = base_name.rfind('-', 0, last_hyphen_index)
logger.warning(f"Could not confidently parse version from '{filename}'. Treating '{base_name}' as name.")
logger.warning(f"Could not parse version from '{filename}'. Treating '{base_name}' as name.")
name = base_name.replace('_', '.')
version = ""
return name, version
def find_and_extract_sd(tgz_path, resource_identifier):
"""
Helper to find and extract StructureDefinition json from a given tgz path.
Matches by resource ID, Name, or Type defined within the SD file.
Returns (sd_data, found_path_in_tar) or (None, None).
"""
"""Helper to find and extract StructureDefinition json from a tgz path."""
sd_data = None
found_path = None
logger = logging.getLogger(__name__)
if not tgz_path or not os.path.exists(tgz_path):
logger.error(f"File not found in find_and_extract_sd: {tgz_path}")
return None, None
@ -371,7 +360,6 @@ def process_package_file(tgz_path):
if must_support is True:
if element_id and element_path:
# Use id for sliced elements to ensure uniqueness
ms_path = element_id if slice_name else element_path
ms_paths_in_this_sd.add(ms_path)
has_ms_in_this_sd = True
@ -432,7 +420,6 @@ def process_package_file(tgz_path):
resource_type = data.get('resourceType')
if not resource_type: continue
# Prioritize meta.profile matches (from oldest version for reliability)
profile_meta = data.get('meta', {}).get('profile', [])
found_profile_match = False
if profile_meta and isinstance(profile_meta, list):
@ -446,11 +433,10 @@ def process_package_file(tgz_path):
break
elif profile_url in resource_info:
associated_key = profile_url
found_profile_match = True
personally_match = True
logger.debug(f"Example {member.name} associated with profile {associated_key} via meta.profile")
break
# Fallback to base type
if not found_profile_match:
key_to_use = resource_type
if key_to_use not in resource_info:
@ -459,7 +445,6 @@ def process_package_file(tgz_path):
logger.debug(f"Example {member.name} associated with resource type {associated_key}")
referenced_types.add(resource_type)
else:
# Handle non-JSON examples (from oldest version)
guessed_type = base_filename_lower.split('-')[0].capitalize()
guessed_profile_id = base_filename_lower.split('-')[0]
key_to_use = None
@ -574,16 +559,12 @@ def process_package_file(tgz_path):
# --- Validation Functions ---
def navigate_fhir_path(resource, path, extension_url=None):
"""
Navigates a FHIR resource using a FHIRPath-like expression.
Returns the value(s) at the specified path or None if not found.
"""
"""Navigates a FHIR resource using a FHIRPath-like expression."""
if not resource or not path:
return None
parts = path.split('.')
current = resource
for part in parts:
# Handle array indexing (e.g., element[0])
match = re.match(r'^(\w+)\[(\d+)\]$', part)
if match:
key, index = match.groups()
@ -596,21 +577,16 @@ def navigate_fhir_path(resource, path, extension_url=None):
else:
return None
else:
# Handle regular path components
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
# Handle extension filtering
if extension_url and isinstance(current, list):
current = [item for item in current if item.get('url') == extension_url]
return current
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
"""
Validates a FHIR resource against a StructureDefinition in the specified package.
Returns a dict with 'valid', 'errors', and 'warnings'.
"""
"""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': []}
download_dir = _get_download_dir()
@ -629,14 +605,13 @@ def validate_resource_against_profile(package_name, version, resource, include_d
# Basic validation: check required elements and Must Support
elements = sd_data.get('snapshot', {}).get('element', [])
for element in elements:
if element.get('min', 0) > 0:
path = element.get('path')
if element.get('min', 0) > 0:
value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not value):
result['valid'] = False
result['errors'].append(f"Required element {path} missing")
if element.get('mustSupport', False):
path = element.get('path')
value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not value):
result['warnings'].append(f"Must Support element {path} missing or empty")
@ -644,10 +619,7 @@ def validate_resource_against_profile(package_name, version, resource, include_d
return result
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
"""
Validates a FHIR Bundle against profiles in the specified package.
Returns a dict with 'valid', 'errors', 'warnings', and per-resource 'results'.
"""
"""Validates a FHIR Bundle against profiles in the specified package."""
logger.debug(f"Validating bundle against {package_name}#{version}")
result = {
'valid': True,
@ -675,6 +647,74 @@ def validate_bundle_against_profile(package_name, version, bundle, include_depen
return result
# --- Structure Definition Retrieval ---
def get_structure_definition(package_name, version, resource_type):
"""Fetches StructureDefinition with slicing support."""
download_dir = _get_download_dir()
if not download_dir:
logger.error("Could not get download directory.")
return {'error': 'Download directory not accessible'}
tgz_filename = construct_tgz_filename(package_name, version)
tgz_path = os.path.join(download_dir, tgz_filename)
sd_data, sd_path = find_and_extract_sd(tgz_path, resource_type)
if not sd_data:
# Fallback to canonical package
canonical_tgz = construct_tgz_filename(*CANONICAL_PACKAGE)
canonical_path = os.path.join(download_dir, canonical_tgz)
sd_data, sd_path = find_and_extract_sd(canonical_path, resource_type)
if sd_data:
logger.info(f"Using canonical SD for {resource_type} from {canonical_path}")
elements = sd_data.get('snapshot', {}).get('element', [])
return {
'elements': elements,
'must_support_paths': [el['path'] for el in elements if el.get('mustSupport', False)],
'slices': [],
'fallback_used': True,
'source_package': f"{CANONICAL_PACKAGE[0]}#{CANONICAL_PACKAGE[1]}"
}
logger.error(f"No StructureDefinition found for {resource_type} in {package_name}#{version} or canonical package")
return {'error': f"No StructureDefinition for {resource_type}"}
elements = sd_data.get('snapshot', {}).get('element', [])
must_support_paths = []
slices = []
# Process elements for must-support and slicing
for element in elements:
path = element.get('path', '')
element_id = element.get('id', '')
slice_name = element.get('sliceName')
if element.get('mustSupport', False):
ms_path = element_id if slice_name else path
must_support_paths.append(ms_path)
if 'slicing' in element:
slice_info = {
'path': path,
'sliceName': slice_name,
'discriminator': element.get('slicing', {}).get('discriminator', []),
'nested_slices': []
}
# Find nested slices
for sub_element in elements:
if sub_element['path'].startswith(path + '.') and 'slicing' in sub_element:
sub_slice_name = sub_element.get('sliceName')
slice_info['nested_slices'].append({
'path': sub_element['path'],
'sliceName': sub_slice_name,
'discriminator': sub_element.get('slicing', {}).get('discriminator', [])
})
slices.append(slice_info)
logger.debug(f"StructureDefinition for {resource_type}: {len(elements)} elements, {len(must_support_paths)} must-support paths, {len(slices)} slices")
return {
'elements': elements,
'must_support_paths': sorted(list(set(must_support_paths))),
'slices': slices,
'fallback_used': False
}
# --- Other Service Functions ---
def _build_package_index(download_dir):
"""Builds an index of canonical URLs to package details from .index.json files."""
@ -721,7 +761,7 @@ def _load_definition(details, download_dir):
"""Loads a StructureDefinition from package details."""
if not details:
return None
tgz_path = os.path.join(download_dir, _construct_tgz_filename(details['package_name'], details['package_version']))
tgz_path = os.path.join(download_dir, construct_tgz_filename(details['package_name'], details['package_version']))
try:
with tarfile.open(tgz_path, "r:gz") as tar:
member_path = f"package/{details['filename']}"
@ -735,7 +775,9 @@ def _load_definition(details, download_dir):
except Exception as e:
logger.error(f"Failed to load definition {details['filename']} from {tgz_path}: {e}")
return None
def download_package(name, version):
"""Downloads a single FHIR package."""
download_dir = _get_download_dir()
if not download_dir: return None, "Download dir error"
filename = construct_tgz_filename(name, version)
@ -760,6 +802,7 @@ def download_package(name, version):
return None, f"File write error: {e}"
def extract_dependencies(tgz_path):
"""Extracts dependencies from package.json."""
package_json_path = "package/package.json"
dependencies = {}
error_message = None
@ -782,18 +825,14 @@ def extract_used_types(tgz_path):
used_types = set()
if not tgz_path or not os.path.exists(tgz_path):
logger.error(f"Cannot extract used types: File not found at {tgz_path}")
return used_types # Return empty set
return used_types
try:
with tarfile.open(tgz_path, "r:gz") as tar:
for member in tar:
# Process only JSON files within the 'package/' directory
if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
continue
# Skip metadata files
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']:
continue
fileobj = None
try:
fileobj = tar.extractfile(member)
@ -801,71 +840,40 @@ def extract_used_types(tgz_path):
content_bytes = fileobj.read()
content_string = content_bytes.decode('utf-8-sig')
data = json.loads(content_string)
if not isinstance(data, dict): continue # Skip if not a valid JSON object
if not isinstance(data, dict): continue
resource_type = data.get('resourceType')
if not resource_type: continue # Skip if no resourceType
# Add the resource type itself
if not resource_type: continue
used_types.add(resource_type)
# --- StructureDefinition Specific Extraction ---
if resource_type == 'StructureDefinition':
# Add the type this SD defines/constrains
sd_type = data.get('type')
if sd_type: used_types.add(sd_type)
# Add the base definition type if it's a profile
base_def = data.get('baseDefinition')
if base_def:
base_type = base_def.split('/')[-1]
# Avoid adding primitive types like 'Element', 'Resource' etc. if not needed
if base_type and base_type[0].isupper():
used_types.add(base_type)
# Extract types from elements (snapshot or differential)
if base_type and base_type[0].isupper(): used_types.add(base_type)
elements = data.get('snapshot', {}).get('element', []) or data.get('differential', {}).get('element', [])
for element in elements:
if isinstance(element, dict) and 'type' in element:
for t in element.get('type', []):
# Add code (element type)
code = t.get('code')
if code and code[0].isupper(): used_types.add(code)
# Add targetProfile types (Reference targets)
for profile_uri in t.get('targetProfile', []):
if profile_uri:
profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type)
# Add types from contentReference
content_ref = element.get('contentReference')
if content_ref and content_ref.startswith('#'):
# This usually points to another element path within the same SD
# Trying to resolve this fully can be complex.
# We might infer types based on the path referenced if needed.
pass
# --- General Resource Type Extraction ---
else:
# Look for meta.profile for referenced profiles -> add profile type
profiles = data.get('meta', {}).get('profile', [])
for profile_uri in profiles:
if profile_uri:
profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type)
# ValueSet: Check compose.include.system (often points to CodeSystem)
if resource_type == 'ValueSet':
for include in data.get('compose', {}).get('include', []):
system = include.get('system')
# Heuristic: If it looks like a FHIR core codesystem URL, extract type
if system and system.startswith('http://hl7.org/fhir/'):
type_name = system.split('/')[-1]
# Check if it looks like a ResourceType
if type_name and type_name[0].isupper() and not type_name.startswith('sid'): # Avoid things like sid/us-ssn
if type_name and type_name[0].isupper() and not type_name.startswith('sid'):
used_types.add(type_name)
# Could add more heuristics for other terminology servers
# CapabilityStatement: Check rest.resource.type and rest.resource.profile
if resource_type == 'CapabilityStatement':
for rest_item in data.get('rest', []):
for resource_item in rest_item.get('resource', []):
@ -875,60 +883,36 @@ def extract_used_types(tgz_path):
if profile_uri:
profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type)
# --- Generic recursive search for 'reference' fields? ---
# This could be expensive. Let's rely on SDs for now.
# def find_references(obj):
# if isinstance(obj, dict):
# for k, v in obj.items():
# if k == 'reference' and isinstance(v, str):
# ref_type = v.split('/')[0]
# if ref_type and ref_type[0].isupper(): used_types.add(ref_type)
# else:
# find_references(v)
# elif isinstance(obj, list):
# for item in obj:
# find_references(item)
# find_references(data)
except json.JSONDecodeError as e:
logger.warning(f"Could not parse JSON in {member.name} for used types: {e}")
logger.warning(f"Could not parse JSON in {member.name}: {e}")
except UnicodeDecodeError as e:
logger.warning(f"Could not decode {member.name} for used types: {e}")
logger.warning(f"Could not decode {member.name}: {e}")
except Exception as e:
logger.warning(f"Could not process member {member.name} for used types: {e}")
logger.warning(f"Could not process member {member.name}: {e}")
finally:
if fileobj:
fileobj.close()
except tarfile.ReadError as e:
logger.error(f"Tar ReadError extracting used types from {tgz_path}: {e}")
except tarfile.TarError as e:
logger.error(f"TarError extracting used types from {tgz_path}: {e}")
except FileNotFoundError:
logger.error(f"Package file not found for used type extraction: {tgz_path}")
logger.error(f"Package file not found: {tgz_path}")
except Exception as e:
logger.error(f"Error extracting used types from {tgz_path}: {e}", exc_info=True)
# Filter out potential primitives or base types that aren't resources?
# E.g., 'string', 'boolean', 'Element', 'BackboneElement', 'Resource'
core_non_resource_types = {'string', 'boolean', 'integer', 'decimal', 'uri', 'url', 'canonical',
'base64Binary', 'instant', 'date', 'dateTime', 'time', 'code', 'oid', 'id',
'markdown', 'unsignedInt', 'positiveInt', 'xhtml',
'Element', 'BackboneElement', 'Resource', 'DomainResource', 'DataType'}
core_non_resource_types = {
'string', 'boolean', 'integer', 'decimal', 'uri', 'url', 'canonical', 'base64Binary', 'instant',
'date', 'dateTime', 'time', 'code', 'oid', 'id', 'markdown', 'unsignedInt', 'positiveInt', 'xhtml',
'Element', 'BackboneElement', 'Resource', 'DomainResource', 'DataType'
}
final_used_types = {t for t in used_types if t not in core_non_resource_types and t[0].isupper()}
logger.debug(f"Extracted used types from {os.path.basename(tgz_path)}: {final_used_types}")
return final_used_types
def map_types_to_packages(used_types, all_dependencies, download_dir):
"""Maps used types to packages by checking .index.json files for exported types."""
"""Maps used types to packages by checking .index.json files."""
type_to_package = {}
processed_types = set()
# Load .index.json from each package to map types
for (pkg_name, pkg_version), _ in all_dependencies.items():
tgz_filename = construct_tgz_filename(pkg_name, pkg_version)
tgz_path = os.path.join(download_dir, tgz_filename)
@ -950,30 +934,25 @@ def map_types_to_packages(used_types, all_dependencies, download_dir):
if sd_name in used_types:
type_to_package[sd_name] = (pkg_name, pkg_version)
processed_types.add(sd_name)
logger.debug(f"Mapped type '{sd_name}' to package '{pkg_name}#{pkg_version}' via .index.json")
logger.debug(f"Mapped type '{sd_name}' to package '{pkg_name}#{pkg_version}'")
except Exception as e:
logger.warning(f"Failed to process .index.json for {pkg_name}#{pkg_version}: {e}")
# Fallback: Use heuristic matching for unmapped types
for t in used_types - processed_types:
for (pkg_name, pkg_version), _ in all_dependencies.items():
if t.lower() in pkg_name.lower():
type_to_package[t] = (pkg_name, pkg_version)
processed_types.add(t)
logger.debug(f"Fallback: Mapped type '{t}' to package '{pkg_name}#{pkg_version}' via name heuristic")
logger.debug(f"Fallback: Mapped type '{t}' to package '{pkg_name}#{pkg_version}'")
break
# Final fallback: Map remaining types to canonical package
canonical_name, canonical_version = CANONICAL_PACKAGE
for t in used_types - processed_types:
type_to_package[t] = CANONICAL_PACKAGE
logger.debug(f"Fallback: Mapped type '{t}' to canonical package {canonical_name}#{canonical_version}")
logger.debug(f"Final type-to-package mapping: {type_to_package}")
return type_to_package
def import_package_and_dependencies(initial_name, initial_version, dependency_mode='recursive'):
"""Orchestrates recursive download and dependency extraction based on the dependency mode."""
"""Orchestrates recursive download and dependency extraction."""
logger.info(f"Starting import for {initial_name}#{initial_version} with dependency_mode={dependency_mode}")
download_dir = _get_download_dir()
if not download_dir:
@ -994,17 +973,14 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
while pending_queue:
name, version = pending_queue.pop(0)
package_id_tuple = (name, version)
if package_id_tuple in results['processed']:
logger.debug(f"Skipping already processed package: {name}#{version}")
continue
logger.info(f"Processing package from queue: {name}#{version}")
save_path, dl_error = download_package(name, version)
if dl_error:
results['errors'].append(f"Download failed for {name}#{version}: {dl_error}")
continue
results['downloaded'][package_id_tuple] = save_path
dependencies, dep_error = extract_dependencies(save_path)
if dep_error:
@ -1015,10 +991,8 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
results['errors'].append(f"Dependency extraction returned critical error for {name}#{version}.")
results['processed'].add(package_id_tuple)
continue
results['all_dependencies'][package_id_tuple] = dependencies
results['processed'].add(package_id_tuple)
current_package_deps = []
for dep_name, dep_version in dependencies.items():
if isinstance(dep_name, str) and isinstance(dep_version, str) and dep_name and dep_version:
@ -1027,22 +1001,17 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
if dep_tuple not in all_found_dependencies:
all_found_dependencies.add(dep_tuple)
results['dependencies'].append({"name": dep_name, "version": dep_version})
if dep_tuple not in queued_or_processed_lookup:
should_queue = False
if dependency_mode == 'recursive':
should_queue = True
elif dependency_mode == 'patch-canonical' and dep_tuple == CANONICAL_PACKAGE:
should_queue = True
# Tree-shaking dependencies are handled post-loop for the initial package
if should_queue:
logger.debug(f"Adding dependency to queue ({dependency_mode}): {dep_name}#{dep_version}")
pending_queue.append(dep_tuple)
queued_or_processed_lookup.add(dep_tuple)
save_package_metadata(name, version, dependency_mode, current_package_deps)
# Tree-shaking: Process dependencies for the initial package
if dependency_mode == 'tree-shaking' and package_id_tuple == (initial_name, initial_version):
logger.info(f"Performing tree-shaking for {initial_name}#{initial_version}")
used_types = extract_used_types(save_path)
@ -1052,19 +1021,16 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
if CANONICAL_PACKAGE not in tree_shaken_deps:
tree_shaken_deps.add(CANONICAL_PACKAGE)
logger.debug(f"Ensuring canonical package {CANONICAL_PACKAGE} for tree-shaking")
for dep_tuple in tree_shaken_deps:
if dep_tuple not in queued_or_processed_lookup:
logger.info(f"Queueing tree-shaken dependency: {dep_tuple[0]}#{dep_tuple[1]}")
pending_queue.append(dep_tuple)
queued_or_processed_lookup.add(dep_tuple)
results['dependencies'] = [{"name": d[0], "version": d[1]} for d in all_found_dependencies]
logger.info(f"Import finished for {initial_name}#{initial_version}. Processed: {len(results['processed'])}, Downloaded: {len(results['downloaded'])}, Errors: {len(results['errors'])}")
return results
# --- Standalone Test/Example Usage ---
# --- Standalone Test ---
if __name__ == '__main__':
logger.info("Running services.py directly for testing.")
class MockFlask:

View File

@ -52,7 +52,6 @@
<tbody>
{% for pkg in packages %}
{% set is_processed = (pkg.name, pkg.version) in processed_ids %}
<!---{% set is_duplicate = pkg.name in duplicate_names and duplicate_names[pkg.name]|length > 1 %}--->
{% set is_duplicate = pkg.name in duplicate_groups %}
{% set group_color = group_colors[pkg.name] if (is_duplicate and pkg.name in group_colors) else 'bg-warning' if is_duplicate else '' %}
<tr class="{% if group_color %}{{ group_color }} text-dark{% endif %}">
@ -109,7 +108,10 @@
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
<div class="card-body">
{% if processed_list %}
<p class="mb-2"><small><span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements</small></p>
<p class="mb-2"><small>
<span class="badge bg-warning text-dark border me-1">MS</span> = Contains Must Support Elements<br>
<span class="badge bg-info text-dark border me-1">Optional MS Ext</span> = Optional Extension with Must Support Sub-Elements
</small></p>
<p class="mb-2 small text-muted">Resource Types in the list will be both Profile and Base Type:</p>
<div class="table-responsive">
<table class="table table-sm table-hover">
@ -126,7 +128,8 @@
{% if types_info %}
<div class="d-flex flex-wrap gap-1">
{% for type_info in types_info %}
<span class="badge {% if type_info.must_support %}bg-warning text-dark{% else %}bg-light text-dark{% endif %} border" title="{% if type_info.must_support %}Has Must Support{% endif %}">{{ type_info.name }}</span>
<span class="badge {% if type_info.must_support %}{% if type_info.optional_usage %}bg-info{% else %}bg-warning{% endif %} text-dark{% else %}bg-light text-dark{% endif %} border"
title="{% if type_info.must_support %}{% if type_info.optional_usage %}Optional Extension with Must Support{% else %}Has Must Support{% endif %}{% endif %}">{{ type_info.name }}</span>
{% endfor %}
</div>
{% else %}

View File

@ -84,24 +84,17 @@
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}" {# Use name which is usually the profile ID #}
data-resource-type="{{ type_info.name }}"
aria-label="View structure for {{ type_info.name }}">
{# --- Badge Logic --- #}
{% if type_info.must_support %}
{# Check if it's marked as optional_usage (Extension with internal MS) #}
{% if optional_usage_elements.get(type_info.name) %}
{# Blue/Info badge for Optional Extension with MS #}
<span class="badge bg-info text-dark border" title="Optional Extension with Must Support sub-elements (e.g., value[x], type)">{{ type_info.name }}</span>
<span class="badge bg-info text-dark border" title="Optional Extension with Must Support sub-elements">{{ type_info.name }}</span>
{% else %}
{# Yellow/Warning badge for standard Profile with MS #}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% endif %}
{% else %}
{# Default light badge if no Must Support #}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
{# --- End Badge Logic --- #}
</a>
{% endfor %}
</div>
@ -115,11 +108,10 @@
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}" {# Use name which is the resource type #}
data-resource-type="{{ type_info.name }}"
aria-label="View structure for {{ type_info.name }}">
{# Base types usually don't have MS directly, but check just in case #}
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements (Unusual for base type reference)">{{ type_info.name }}</span>
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
@ -193,7 +185,8 @@
{% else %}
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
{% endif %}
</div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -220,15 +213,6 @@ document.addEventListener('DOMContentLoaded', function() {
const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
// Debugging: Log initial data
// console.log("Examples Data:", examplesData);
// Optional: Debug clicks if needed
// document.addEventListener('click', function(event) {
// console.log("Click event on:", event.target, "Class:", event.target.className);
// }, true);
// Handle clicks on resource-type-link within resource-type-list containers
document.querySelectorAll('.resource-type-list').forEach(container => {
container.addEventListener('click', function(event) {
const link = event.target.closest('.resource-type-link');
@ -238,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function() {
const pkgName = link.dataset.packageName;
const pkgVersion = link.dataset.packageVersion;
const resourceType = link.dataset.resourceType; // This is the Profile ID or Resource Type Name
const resourceType = link.dataset.resourceType;
if (!pkgName || !pkgVersion || !resourceType) {
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
return;
@ -248,42 +232,35 @@ document.addEventListener('DOMContentLoaded', function() {
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
console.log("Fetching structure from:", structureFetchUrl);
// Update titles and reset displays
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
structureDisplay.innerHTML = ''; // Clear previous structure
rawStructureContent.textContent = ''; // Clear previous raw structure
structureDisplay.innerHTML = '';
rawStructureContent.textContent = '';
structureLoading.style.display = 'block';
rawStructureLoading.style.display = 'block';
structureFallbackMessage.style.display = 'none';
structureDisplayWrapper.style.display = 'block'; // Show structure section
rawStructureWrapper.style.display = 'block'; // Show raw structure section
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially
structureDisplayWrapper.style.display = 'block';
rawStructureWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'none';
// Fetch Structure Definition
fetch(structureFetchUrl)
.then(response => {
console.log("Structure fetch response status:", response.status);
// Try to parse JSON regardless of status to get error message from body
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
})
.then(result => {
if (!result.ok) {
// Throw error using message from JSON body if available
throw new Error(result.data.error || `HTTP error ${result.status}`);
}
console.log("Structure data received:", result.data);
// Display fallback message if needed
if (result.data.fallback_used) {
structureFallbackMessage.style.display = 'block';
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`;
} else {
structureFallbackMessage.style.display = 'none';
}
// Render the tree and raw JSON
renderStructureTree(result.data.elements, result.data.must_support_paths || []);
rawStructureContent.textContent = JSON.stringify(result.data, null, 2); // Pretty print raw SD
// Populate examples for the clicked resource/profile ID
renderStructureTree(result.data.elements, result.data.must_support_paths || [], resourceType);
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
populateExampleSelector(resourceType);
})
.catch(error => {
@ -291,108 +268,96 @@ document.addEventListener('DOMContentLoaded', function() {
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 show examples if structure fails
populateExampleSelector(resourceType);
})
.finally(() => {
// Hide loading spinners
structureLoading.style.display = 'none';
rawStructureLoading.style.display = 'none';
});
});
});
// Function to populate the example dropdown
function populateExampleSelector(resourceOrProfileIdentifier) {
console.log("Populating examples for:", resourceOrProfileIdentifier);
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier; // Update example card title
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
console.log("Available examples:", availableExamples);
// Reset example section
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>'; // Clear previous options
exampleContentWrapper.style.display = 'none'; // Hide content area
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
exampleContentWrapper.style.display = 'none';
exampleFilename.textContent = '';
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
if (availableExamples.length > 0) {
// Add new options
availableExamples.forEach(filePath => {
const filename = filePath.split('/').pop(); // Get filename from path
const filename = filePath.split('/').pop();
const option = document.createElement('option');
option.value = filePath;
option.textContent = filename;
exampleSelect.appendChild(option);
});
exampleSelectorWrapper.style.display = 'block'; // Show dropdown
exampleDisplayWrapper.style.display = 'block'; // Show example card
exampleSelectorWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'block';
} else {
exampleSelectorWrapper.style.display = 'none'; // Hide dropdown if no examples
exampleDisplayWrapper.style.display = 'none'; // Hide example card if no examples
exampleSelectorWrapper.style.display = 'none';
exampleDisplayWrapper.style.display = 'none';
}
}
// Event listener for example selection change
if (exampleSelect) {
exampleSelect.addEventListener('change', function(event) {
const selectedFilePath = this.value;
console.log("Selected example file:", selectedFilePath);
// Reset example content areas
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
exampleFilename.textContent = '';
exampleContentWrapper.style.display = 'none'; // Hide content initially
exampleContentWrapper.style.display = 'none';
if (!selectedFilePath) return; // Do nothing if "-- Select --" is chosen
if (!selectedFilePath) return;
// Prepare URL to fetch example content
const exampleParams = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}",
package_version: "{{ processed_ig.version }}",
filename: selectedFilePath
});
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
const exampleFetchUrl = `${exampleBaseUrl}?${structureParams.toString()}`;
console.log("Fetching example from:", exampleFetchUrl);
exampleLoading.style.display = 'block'; // Show loading spinner
exampleLoading.style.display = 'block';
// Fetch example content
fetch(exampleFetchUrl)
.then(response => {
console.log("Example fetch response status:", response.status);
if (!response.ok) {
// Try to get error text from body
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
}
return response.text(); // Get content as text
return response.text();
})
.then(content => {
console.log("Example content received."); // Avoid logging potentially large content
console.log("Example content received.");
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
exampleContentRaw.textContent = content; // Display raw content
// Try to parse and pretty-print if it's JSON
exampleContentRaw.textContent = content;
try {
const jsonContent = JSON.parse(content);
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
} catch (e) {
console.warn("Example content is not valid JSON:", e.message);
exampleContentJson.textContent = '(Not valid JSON)'; // Indicate if not JSON
exampleContentJson.textContent = '(Not valid JSON)';
}
exampleContentWrapper.style.display = 'block'; // Show content area
exampleContentWrapper.style.display = 'block';
})
.catch(error => {
console.error("Error fetching example:", error);
exampleFilename.textContent = 'Error loading example';
exampleContentRaw.textContent = `Error: ${error.message}`;
exampleContentJson.textContent = `Error: ${error.message}`;
exampleContentWrapper.style.display = 'block'; // Show error in content area
exampleContentWrapper.style.display = 'block';
})
.finally(() => {
exampleLoading.style.display = 'none'; // Hide loading spinner
exampleLoading.style.display = 'none';
});
});
}
// Function to build the hierarchical data structure for the tree
function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot };
@ -415,14 +380,15 @@ document.addEventListener('DOMContentLoaded', function() {
let parentNode = treeRoot;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentPath = i === 0 ? part : `${currentPath}.${part}`;
let part = parts[i];
const isChoiceElement = part.endsWith('[x]');
const cleanPart = part.replace(/\[\d+\]/g, '').replace('[x]', '');
currentPath = i === 0 ? cleanPart : `${currentPath}.${cleanPart}`;
// For extensions, append sliceName to path if present
let nodeKey = part;
if (part === 'extension' && i === parts.length - 1 && sliceName) {
nodeKey = `${part}:${sliceName}`;
currentPath = id || currentPath; // Use id for precise matching in extensions
let nodeKey = isChoiceElement ? part : cleanPart;
if (sliceName && i === parts.length - 1) {
nodeKey = sliceName;
currentPath = id || currentPath;
}
if (!nodeMap[currentPath]) {
@ -432,17 +398,68 @@ document.addEventListener('DOMContentLoaded', function() {
name: nodeKey,
path: currentPath
};
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.');
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.').replace(/\[\d+\]/g, '').replace('[x]', '');
parentNode = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}`);
// Add modifierExtension only 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}`);
}
}
if (i === parts.length - 1) {
const targetNode = nodeMap[currentPath];
targetNode.element = el;
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
// 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];
@ -454,8 +471,7 @@ document.addEventListener('DOMContentLoaded', function() {
return treeData;
}
// Function to render a single node (and its children recursively) as an <li>
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
if (!node || !node.element) {
console.warn("Skipping render for invalid node:", node);
return '';
@ -470,24 +486,11 @@ document.addEventListener('DOMContentLoaded', function() {
const definition = el.definition || '';
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
console.log(` MustSupportPathsSet contains path: ${mustSupportPathsSet.has(path)}`);
if (id) {
console.log(` MustSupportPathsSet contains id: ${mustSupportPathsSet.has(id)}`);
}
// Check MS for path, id, or normalized extension path
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
if (!isMustSupport && path.startsWith('Extension.extension')) {
const basePath = path.split(':')[0];
const baseId = id ? id.split(':')[0] : null;
isMustSupport = mustSupportPathsSet.has(basePath) || (baseId && mustSupportPathsSet.has(baseId));
console.log(` Extension check: basePath=${basePath}, baseId=${baseId}, isMustSupport=${isMustSupport}`);
}
console.log(` Final isMustSupport for ${path}: ${isMustSupport}`);
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 text-warning ms-1" title="Must Support"></i>' : '';
const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${isOptional ? 'Optional Must Support Extension' : 'Must Support'}"></i>` : '';
const hasChildren = Object.keys(node.children).length > 0;
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20;
@ -510,15 +513,15 @@ document.addEventListener('DOMContentLoaded', function() {
let childrenHtml = '';
if (hasChildren) {
childrenHtml += `<ul class="collapse list-group list-group-flush structure-subtree" id="${collapseId}">`;
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, level + 1);
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1);
});
childrenHtml += `</ul>`;
}
let itemHtml = `<li class="${liClass}">`;
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" data-bs-target="#${collapseId}" role="button" 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) {
@ -533,11 +536,11 @@ document.addEventListener('DOMContentLoaded', function() {
let descriptionTooltipAttrs = '';
if (definition) {
const escapedDefinition = definition
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '\'')
.replace(/\n/g, ' ');
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
}
@ -554,19 +557,12 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
// Filter must_support_paths to avoid generic extension paths
const validMustSupportPaths = mustSupportPaths.filter(path => {
if (path.includes('.extension') && !path.includes(':')) {
return false; // Exclude generic Patient.extension
}
return true;
});
const mustSupportPathsSet = new Set(validMustSupportPaths);
const mustSupportPathsSet = new Set(mustSupportPaths);
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
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 (blue row)</small></p>';
'<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional Must Support Extension (blue icon)</small></p>';
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path</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">';
@ -586,35 +582,72 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Initialize collapse with manual click handling and debouncing
let lastClickTime = 0;
const debounceDelay = 200; // ms
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
const collapseTargetId = toggleEl.getAttribute('data-bs-target');
if (!collapseTargetId) return;
const collapseEl = document.querySelector(collapseTargetId);
const collapseId = toggleEl.getAttribute('data-collapse-id');
if (!collapseId) {
console.warn("No collapse ID for toggle:", toggleEl.outerHTML);
return;
}
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 for:", collapseId);
return;
}
if (collapseEl && toggleIcon) {
collapseEl.classList.remove('show');
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
console.log("Initializing collapse for:", collapseId);
// Initialize Bootstrap Collapse
const bsCollapse = new bootstrap.Collapse(collapseEl, {
toggle: false
});
// Custom click handler with debounce
toggleEl.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const isCurrentlyShown = collapseEl.classList.contains('show');
toggleIcon.classList.toggle('bi-chevron-right', isCurrentlyShown);
toggleIcon.classList.toggle('bi-chevron-down', !isCurrentlyShown);
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'));
if (collapseEl.classList.contains('show')) {
bsCollapse.hide();
} else {
bsCollapse.show();
}
});
collapseEl.addEventListener('show.bs.collapse', () => {
// 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', () => {
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');
});
}
});
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 (e) {
console.error("JavaScript error in cp_view_processed_ig.html:", e);

View File

@ -0,0 +1,577 @@
{% extends "base.html" %}
{% block content %}
<div class="px-4 py-5 my-5 text-center">
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE Ig Toolkit" width="192" height="192">
<h1 class="display-5 fw-bold text-body-emphasis">{{ title }}</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
View details of the processed FHIR Implementation Guide.
</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
</div>
</div>
</div>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-info-circle me-2"></i>{{ title }}</h2>
<a href="{{ url_for('view_igs') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Package List</a>
</div>
{% if processed_ig %}
<div class="card">
<div class="card-header">Package Details</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Package Name</dt>
<dd class="col-sm-9"><code>{{ processed_ig.package_name }}</code></dd>
<dt class="col-sm-3">Package Version</dt>
<dd class="col-sm-9">{{ processed_ig.version }}</dd>
<dt class="col-sm-3">Processed At</dt>
<dd class="col-sm-9">{{ processed_ig.processed_date.strftime('%Y-%m-%d %H:%M:%S UTC') }}</dd>
</dl>
</div>
</div>
{% if config.DISPLAY_PROFILE_RELATIONSHIPS %}
<div class="card mt-4">
<div class="card-header">Profile Relationships</div>
<div class="card-body">
<h6>Complies With</h6>
{% if complies_with_profiles %}
<ul>
{% for profile in complies_with_profiles %}
<li>{{ profile }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted"><em>No profiles declared as compatible.</em></p>
{% endif %}
<h6>Required Dependent Profiles (Must Also Validate Against)</h6>
{% if imposed_profiles %}
<ul>
{% for profile in imposed_profiles %}
<li>{{ profile }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted"><em>No imposed profiles.</em></p>
{% endif %}
</div>
</div>
{% endif %}
<div class="card mt-4">
<div class="card-header">Resource Types Found / Defined</div>
<div class="card-body">
{% if profile_list or base_list %}
<p class="mb-2">
<small>
<span class="badge bg-warning text-dark border me-1" style="font-size: 0.8em;" title="Profile contains Must Support elements">MS</span> = Contains Must Support Elements<br>
<span class="badge bg-info text-dark border me-1" style="font-size: 0.8em;" title="An optional Extension which contains Must Support sub-elements (e.g., value[x], type)">Optional MS Ext</span> = Optional Extension with Must Support Sub-Elements
</small>
</p>
{% if profile_list %}
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = Examples will be displayed when selecting profile Types if contained in the IG</small></p>
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
{% for type_info in profile_list %}
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}"
aria-label="View structure for {{ type_info.name }}">
{% if type_info.must_support %}
{% if optional_usage_elements.get(type_info.name) %}
<span class="badge bg-info text-dark border" title="Optional Extension with Must Support sub-elements">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% endif %}
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted"><em>No profiles defined.</em></p>
{% endif %}
{% if base_list %}
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
<div class="d-flex flex-wrap gap-1 resource-type-list">
{% for type_info in base_list %}
<a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}"
data-resource-type="{{ type_info.name }}"
aria-label="View structure for {{ type_info.name }}">
{% if type_info.must_support %}
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted"><em>No base resource types referenced.</em></p>
{% endif %}
{% else %}
<p class="text-muted"><em>No resource type information extracted or stored.</em></p>
{% endif %}
</div>
</div>
<div id="structure-display-wrapper" class="mt-4" style="display: none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Structure Definition for: <code id="structure-title"></code></span>
<button type="button" class="btn-close" aria-label="Close" onclick="document.getElementById('structure-display-wrapper').style.display='none'; document.getElementById('example-display-wrapper').style.display='none'; document.getElementById('raw-structure-wrapper').style.display='none';"></button>
</div>
<div class="card-body">
<div id="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>
<div id="structure-fallback-message" class="alert alert-info" style="display: none;"></div>
<div id="structure-content"></div>
</div>
</div>
</div>
<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">
<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>
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
</div>
</div>
</div>
<div id="example-display-wrapper" class="mt-4" style="display: none;">
<div class="card">
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
<div class="card-body">
<div class="mb-3" id="example-selector-wrapper" style="display: none;">
<label for="example-select" class="form-label">Select Example:</label>
<select class="form-select form-select-sm" id="example-select"><option selected value="">-- Select --</option></select>
</div>
<div id="example-loading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
</div>
<div id="example-content-wrapper" style="display: none;">
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
<div class="row">
<div class="col-md-6">
<h6>Raw Content</h6>
<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">
<h6>Pretty-Printed JSON</h6>
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
const structureDisplay = document.getElementById('structure-content');
const structureTitle = document.getElementById('structure-title');
const structureLoading = document.getElementById('structure-loading');
const structureFallbackMessage = document.getElementById('structure-fallback-message');
const exampleDisplayWrapper = document.getElementById('example-display-wrapper');
const exampleSelectorWrapper = document.getElementById('example-selector-wrapper');
const exampleSelect = document.getElementById('example-select');
const exampleResourceTypeTitle = document.getElementById('example-resource-type-title');
const exampleLoading = document.getElementById('example-loading');
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');
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');
const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
document.querySelectorAll('.resource-type-list').forEach(container => {
container.addEventListener('click', function(event) {
const link = event.target.closest('.resource-type-link');
if (!link) return;
event.preventDefault();
console.log("Resource type link clicked:", link.dataset.resourceType);
const pkgName = link.dataset.packageName;
const pkgVersion = link.dataset.packageVersion;
const resourceType = link.dataset.resourceType;
if (!pkgName || !pkgVersion || !resourceType) {
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
return;
}
const structureParams = new URLSearchParams({ package_name: pkgName, package_version: pkgVersion, resource_type: resourceType });
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
console.log("Fetching structure from:", structureFetchUrl);
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
structureDisplay.innerHTML = '';
rawStructureContent.textContent = '';
structureLoading.style.display = 'block';
rawStructureLoading.style.display = 'block';
structureFallbackMessage.style.display = 'none';
structureDisplayWrapper.style.display = 'block';
rawStructureWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'none';
fetch(structureFetchUrl)
.then(response => {
console.log("Structure fetch response status:", response.status);
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
})
.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 ${result.data.source_package}.`;
} else {
structureFallbackMessage.style.display = 'none';
}
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 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}`;
populateExampleSelector(resourceType);
})
.finally(() => {
structureLoading.style.display = 'none';
rawStructureLoading.style.display = 'none';
});
});
});
function populateExampleSelector(resourceOrProfileIdentifier) {
console.log("Populating examples for:", resourceOrProfileIdentifier);
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
console.log("Available examples:", availableExamples);
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
exampleContentWrapper.style.display = 'none';
exampleFilename.textContent = '';
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
if (availableExamples.length > 0) {
availableExamples.forEach(filePath => {
const filename = filePath.split('/').pop();
const option = document.createElement('option');
option.value = filePath;
option.textContent = filename;
exampleSelect.appendChild(option);
});
exampleSelectorWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'block';
} else {
exampleSelectorWrapper.style.display = 'none';
exampleDisplayWrapper.style.display = 'none';
}
}
if (exampleSelect) {
exampleSelect.addEventListener('change', function(event) {
const selectedFilePath = this.value;
console.log("Selected example file:", selectedFilePath);
exampleContentRaw.textContent = '';
exampleContentJson.textContent = '';
exampleFilename.textContent = '';
exampleContentWrapper.style.display = 'none';
if (!selectedFilePath) return;
const exampleParams = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}",
package_version: "{{ processed_ig.version }}",
filename: selectedFilePath
});
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
console.log("Fetching example from:", exampleFetchUrl);
exampleLoading.style.display = 'block';
fetch(exampleFetchUrl)
.then(response => {
console.log("Example fetch response status:", response.status);
if (!response.ok) {
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
}
return response.text();
})
.then(content => {
console.log("Example content received.");
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
exampleContentRaw.textContent = content;
try {
const jsonContent = JSON.parse(content);
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
} catch (e) {
console.warn("Example content is not valid JSON:", e.message);
exampleContentJson.textContent = '(Not valid JSON)';
}
exampleContentWrapper.style.display = 'block';
})
.catch(error => {
console.error("Error fetching example:", error);
exampleFilename.textContent = 'Error loading example';
exampleContentRaw.textContent = `Error: ${error.message}`;
exampleContentJson.textContent = `Error: ${error.message}`;
exampleContentWrapper.style.display = 'block';
})
.finally(() => {
exampleLoading.style.display = 'none';
});
});
}
function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot };
console.log("Building tree with elements:", elements.length);
elements.forEach((el, index) => {
const path = el.path;
const id = el.id || null;
const sliceName = el.sliceName || null;
console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`);
if (!path) {
console.warn(`Skipping element ${index} with no path`);
return;
}
const parts = path.split('.');
let currentPath = '';
let parentNode = treeRoot;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const cleanPart = part.replace(/\[\d+\]/g, '');
currentPath = i === 0 ? cleanPart : `${currentPath}.${cleanPart}`;
let nodeKey = cleanPart;
if (sliceName && i === parts.length - 1) {
nodeKey = sliceName;
currentPath = id || currentPath;
}
if (!nodeMap[currentPath]) {
const newNode = {
children: {},
element: null,
name: nodeKey,
path: currentPath
};
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.').replace(/\[\d+\]/g, '');
parentNode = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}`);
}
if (i === parts.length - 1) {
const targetNode = nodeMap[currentPath];
targetNode.element = el;
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
}
parentNode = nodeMap[currentPath];
}
});
const treeData = Object.values(treeRoot.children);
console.log("Tree data constructed:", treeData);
return treeData;
}
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
if (!node || !node.element) {
console.warn("Skipping render for invalid node:", node);
return '';
}
const el = node.element;
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}, sliceName=${sliceName}`);
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
// Blue tick boxes only for optional extensions with must-support sub-elements
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-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20;
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
let typeString = 'N/A';
if (el.type && el.type.length > 0) {
typeString = el.type.map(t => {
let s = t.code || '';
let profiles = t.targetProfile || t.profile || [];
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(', ')}">${targetTypes}</span>)`;
}
}
return s;
}).join(' | ');
}
let childrenHtml = '';
if (hasChildren) {
childrenHtml += `<ul class="collapse list-group list-group-flush structure-subtree" id="${collapseId}">`;
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1);
});
childrenHtml += `</ul>`;
}
let itemHtml = `<li class="${liClass}">`;
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `data-bs-toggle="collapse" data-bs-target="#${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) {
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
}
itemHtml += `</span>`;
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) {
const escapedDefinition = definition
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\n/g, ' ');
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
}
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>';
return;
}
const mustSupportPathsSet = new Set(mustSupportPaths);
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
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);
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>';
structureDisplay.innerHTML = html;
const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(tooltipTriggerEl => {
if (tooltipTriggerEl.getAttribute('title')) {
new bootstrap.Tooltip(tooltipTriggerEl);
}
});
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
const collapseTargetId = toggleEl.getAttribute('data-bs-target');
if (!collapseTargetId) return;
const collapseEl = document.querySelector(collapseTargetId);
const toggleIcon = toggleEl.querySelector('.toggle-icon');
if (collapseEl && toggleIcon) {
collapseEl.classList.remove('show');
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
toggleEl.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const isCurrentlyShown = collapseEl.classList.contains('show');
toggleIcon.classList.toggle('bi-chevron-right', isCurrentlyShown);
toggleIcon.classList.toggle('bi-chevron-down', !isCurrentlyShown);
});
collapseEl.addEventListener('show.bs.collapse', () => {
toggleIcon.classList.remove('bi-chevron-right');
toggleIcon.classList.add('bi-chevron-down');
});
collapseEl.addEventListener('hide.bs.collapse', () => {
toggleIcon.classList.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right');
});
}
});
}
} catch (e) {
console.error("JavaScript error in cp_view_processed_ig.html:", e);
const body = document.querySelector('body');
if (body) {
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.';
body.insertBefore(errorDiv, body.firstChild);
}
}
});
</script>
{% endblock %}

View File

@ -27,11 +27,11 @@
Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
</small>
<div class="mt-2">
<select class="form-select" id="packageSelect" name="packageSelect" onchange="updatePackageFields(this)">
<select class="form-select" id="packageSelect" onchange="updatePackageFields(this)">
<option value="">-- Select a Package --</option>
{% if packages %}
{% for pkg in packages %}
<option value="{{ pkg.id }}">{{ pkg.text }}</option>
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
{% endfor %}
{% else %}
<option value="" disabled>No packages available</option>
@ -39,14 +39,14 @@
</select>
</div>
<label for="{{ form.package_name.id }}" class="form-label">Package Name</label>
{{ form.package_name(class="form-control") }}
{{ form.package_name(class="form-control", id=form.package_name.id) }}
{% for error in form.package_name.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.version.id }}" class="form-label">Package Version</label>
{{ form.version(class="form-control") }}
{{ form.version(class="form-control", id=form.version.id) }}
{% for error in form.version.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
@ -125,21 +125,31 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('validationForm');
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}');
// Debug DOM elements
console.log('Package Select:', packageSelect ? 'Found' : 'Missing');
console.log('Package Name Input:', packageNameInput ? 'Found' : 'Missing');
console.log('Version Input:', versionInput ? 'Found' : 'Missing');
// Update package_name and version fields from dropdown
function updatePackageFields(select) {
const value = select.value;
console.log('Selected package:', value);
if (!packageNameInput || !versionInput) {
console.error('Input fields not found');
return;
}
if (value) {
const parts = value.split('#');
if (parts.length === 2 && parts[0] && parts[1]) {
packageNameInput.value = parts[0];
versionInput.value = parts[1];
const [name, version] = value.split('#');
if (name && version) {
packageNameInput.value = name;
versionInput.value = version;
console.log('Updated fields:', { packageName: name, version });
} else {
console.warn('Invalid package format:', value);
packageNameInput.value = '';
@ -148,19 +158,32 @@ document.addEventListener('DOMContentLoaded', function() {
} else {
packageNameInput.value = '';
versionInput.value = '';
console.log('Cleared fields: no package selected');
}
}
// Initialize dropdown with current form values
if (packageNameInput.value && versionInput.value) {
// Initialize dropdown based on form values
if (packageNameInput && versionInput && packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const option = packageSelect.querySelector(`option[value="${currentValue}"]`);
if (option) {
if (packageSelect.querySelector(`option[value="${currentValue}"]`)) {
packageSelect.value = currentValue;
console.log('Initialized dropdown with:', currentValue);
}
}
// Debug dropdown options
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
// Attach event listener explicitly
if (packageSelect) {
packageSelect.addEventListener('change', function() {
updatePackageFields(this);
});
}
// Client-side JSON validation preview
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
if (sampleInput) {
sampleInput.addEventListener('input', function() {
try {
JSON.parse(this.value);
@ -171,9 +194,7 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.add('is-invalid');
}
});
// Debug dropdown options
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => ({value: opt.value, text: opt.textContent})));
}
});
</script>
{% endblock %}