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__) logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# --- Configuration ---
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-fallback-secret-key-here') 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_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/fhir_ig.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
@ -32,7 +31,6 @@ app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
# Ensure directories exist and are writable # Ensure directories exist and are writable
instance_path = '/app/instance' instance_path = '/app/instance'
packages_path = app.config['FHIR_PACKAGES_DIR'] packages_path = app.config['FHIR_PACKAGES_DIR']
logger.debug(f"Instance path configuration: {instance_path}") logger.debug(f"Instance path configuration: {instance_path}")
logger.debug(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}") logger.debug(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
logger.debug(f"Packages path: {packages_path}") logger.debug(f"Packages path: {packages_path}")
@ -49,7 +47,6 @@ except Exception as e:
db = SQLAlchemy(app) db = SQLAlchemy(app)
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
# --- Database Model ---
class ProcessedIg(db.Model): class ProcessedIg(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
package_name = db.Column(db.String(128), nullable=False) 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) complies_with_profiles = db.Column(db.JSON, nullable=True)
imposed_profiles = db.Column(db.JSON, nullable=True) imposed_profiles = db.Column(db.JSON, nullable=True)
optional_usage_elements = 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'),) __table_args__ = (db.UniqueConstraint('package_name', 'version', name='uq_package_version'),)
# --- API Key Middleware ---
def check_api_key(): def check_api_key():
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key')
if not api_key and request.is_json: if not api_key and request.is_json:
@ -78,47 +73,6 @@ def check_api_key():
logger.debug("API key validated successfully") logger.debug("API key validated successfully")
return None 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): def list_downloaded_packages(packages_dir):
packages = [] packages = []
errors = [] errors = []
@ -159,7 +113,6 @@ def list_downloaded_packages(packages_dir):
else: else:
logger.warning(f"Skipping package {filename} due to invalid name ('{name}') or version ('{version}')") logger.warning(f"Skipping package {filename} due to invalid name ('{name}') or version ('{version}')")
errors.append(f"Invalid package {filename}: name='{name}', version='{version}'") errors.append(f"Invalid package {filename}: name='{name}', version='{version}'")
# Build duplicate_groups
name_counts = {} name_counts = {}
for pkg in packages: for pkg in packages:
name = pkg['name'] name = pkg['name']
@ -172,6 +125,47 @@ def list_downloaded_packages(packages_dir):
logger.debug(f"Duplicate groups: {duplicate_groups}") logger.debug(f"Duplicate groups: {duplicate_groups}")
return packages, errors, 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') @app.route('/view-igs')
def view_igs(): def view_igs():
form = FlaskForm() form = FlaskForm()
@ -230,6 +224,8 @@ def process_ig():
try: try:
logger.info(f"Starting processing for {name}#{version} from file {filename}") logger.info(f"Starting processing for {name}#{version} from file {filename}")
package_info = services.process_package_file(tgz_path) 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 = { optional_usage_dict = {
info['name']: True info['name']: True
for info in package_info.get('resource_types_info', []) 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}") errors.append(f"Could not delete metadata for {filename}: {e}")
logger.error(f"Error deleting {metadata_path}: {e}") logger.error(f"Error deleting {metadata_path}: {e}")
if errors: if errors:
for error in errors: flash(error, "error") for error in errors:
flash(error, "error")
elif deleted_files: elif deleted_files:
flash(f"Deleted: {', '.join(deleted_files)}", "success") flash(f"Deleted: {', '.join(deleted_files)}", "success")
else: 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')] base_list = [t for t in processed_ig.resource_types_info if not t.get('is_profile')]
examples_by_type = processed_ig.examples or {} examples_by_type = processed_ig.examples or {}
optional_usage_elements = processed_ig.optional_usage_elements 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 [] complies_with_profiles = processed_ig.complies_with_profiles or []
imposed_profiles = processed_ig.imposed_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', return render_template('cp_view_processed_ig.html',
title=f"View {processed_ig.package_name}#{processed_ig.version}", title=f"View {processed_ig.package_name}#{processed_ig.version}",
processed_ig=processed_ig, processed_ig=processed_ig,
@ -462,6 +461,7 @@ def get_example_content():
package_version = request.args.get('package_version') package_version = request.args.get('package_version')
example_member_path = request.args.get('filename') example_member_path = request.args.get('filename')
if not all([package_name, package_version, example_member_path]): 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 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: if not example_member_path.startswith('package/') or '..' in example_member_path:
logger.warning(f"Invalid example file path requested: {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: for dep in dependencies_to_include:
dep_name = dep.get('name') dep_name = dep.get('name')
dep_version = dep.get('version') 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_filename = services.construct_tgz_filename(dep_name, dep_version)
dep_tgz_path = os.path.join(packages_dir, dep_tgz_filename) dep_tgz_path = os.path.join(packages_dir, dep_tgz_filename)
if os.path.exists(dep_tgz_path): if os.path.exists(dep_tgz_path):
@ -789,83 +790,61 @@ def api_push_ig():
def validate_sample(): def validate_sample():
form = ValidationForm() form = ValidationForm()
validation_report = None validation_report = None
packages_for_template = [] packages = []
packages_dir = app.config.get('FHIR_PACKAGES_DIR') packages_dir = app.config['FHIR_PACKAGES_DIR']
if packages_dir: if os.path.exists(packages_dir):
for filename in os.listdir(packages_dir):
if filename.endswith('.tgz'):
try: try:
all_packages, errors, duplicate_groups = list_downloaded_packages(packages_dir) with tarfile.open(os.path.join(packages_dir, filename), 'r:gz') as tar:
if errors: package_json = tar.extractfile('package/package.json')
flash(f"Warning: Errors encountered while listing packages: {', '.join(errors)}", "warning") if package_json:
filtered_packages = [ pkg_info = json.load(package_json)
pkg for pkg in all_packages name = pkg_info.get('name')
if isinstance(pkg.get('name'), str) and pkg.get('name') and version = pkg_info.get('version')
isinstance(pkg.get('version'), str) and pkg.get('version') if name and version:
] packages.append({'name': name, 'version': 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}")
except Exception as e: except Exception as e:
logger.error(f"Failed to list or process downloaded packages: {e}", exc_info=True) logger.warning(f"Error reading package {filename}: {e}")
flash("Error loading available packages.", "danger") continue
else:
flash("FHIR Packages directory not configured.", "danger")
logger.error("FHIR_PACKAGES_DIR is not configured in the Flask app.")
if form.validate_on_submit(): if form.validate_on_submit():
package_name = form.package_name.data package_name = form.package_name.data
version = form.version.data version = form.version.data
include_dependencies = form.include_dependencies.data include_dependencies = form.include_dependencies.data
mode = form.mode.data mode = form.mode.data
sample_input_raw = form.sample_input.data
try: try:
sample_input = json.loads(sample_input_raw) sample_input = json.loads(form.sample_input.data)
logger.info(f"Starting validation (mode: {mode}) for {package_name}#{version}, deps: {include_dependencies}")
if mode == 'single': if mode == 'single':
validation_report = services.validate_resource_against_profile( validation_report = services.validate_resource_against_profile(
package_name, version, sample_input, include_dependencies package_name, version, sample_input, include_dependencies
) )
elif mode == 'bundle': else:
validation_report = services.validate_bundle_against_profile( validation_report = services.validate_bundle_against_profile(
package_name, version, sample_input, include_dependencies package_name, version, sample_input, include_dependencies
) )
else: flash("Validation completed.", 'success')
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',[]))}")
except json.JSONDecodeError: except json.JSONDecodeError:
flash("Invalid JSON format in sample input.", 'error') 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': {}} 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: except Exception as e:
logger.error(f"Error validating sample: {e}", exc_info=True) logger.error(f"Error validating sample: {e}")
flash(f"An unexpected error occurred during validation: {str(e)}", 'error') flash(f"Error validating sample: {str(e)}", 'error')
validation_report = {'valid': False, 'errors': [f'Unexpected error: {str(e)}'], 'warnings': [], 'results': {}} validation_report = {'valid': False, 'errors': [str(e)], 'warnings': [], 'results': {}}
else: else:
for field, errors in form.errors.items(): for field, errors in form.errors.items():
field_obj = getattr(form, field, None) field_obj = getattr(form, field, None)
field_label = field_obj.label.text if field_obj and hasattr(field_obj, 'label') else field field_label = field_obj.label.text if field_obj and hasattr(field_obj, 'label') else field
for error in errors: for error in errors:
flash(f"Error in field '{field_label}': {error}", "danger") flash(f"Error in field '{field_label}': {error}", "danger")
return render_template( return render_template(
'validate_sample.html', 'validate_sample.html',
form=form, form=form,
packages=packages_for_template, packages=packages,
validation_report=validation_report, validation_report=validation_report,
site_name='FHIRFLARE IG Toolkit', site_name='FHIRFLARE IG Toolkit',
now=datetime.datetime.now() now=datetime.datetime.now()
) )
# --- App Initialization ---
def create_db(): def create_db():
logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}") logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
try: try:

View File

@ -1,18 +1,18 @@
# forms.py # forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField 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 import json
class IgImportForm(FlaskForm): class IgImportForm(FlaskForm):
package_name = StringField('Package Name', validators=[ package_name = StringField('Package Name', validators=[
DataRequired(), DataRequired(),
Regexp(r'^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]$', message="Invalid package name format.") 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=[ package_version = StringField('Package Version', validators=[
DataRequired(), 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).") 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=[ dependency_mode = SelectField('Dependency Mode', choices=[
('recursive', 'Current Recursive'), ('recursive', 'Current Recursive'),
('patch-canonical', 'Patch Canonical Versions'), ('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", "package_name": "hl7.fhir.au.core",
"version": "1.1.0-preview", "version": "1.1.0-preview",
"dependency_mode": "tree-shaking", "dependency_mode": "recursive",
"imported_dependencies": [ "imported_dependencies": [
{ {
"name": "hl7.fhir.r4.core", "name": "hl7.fhir.r4.core",
@ -29,6 +29,5 @@
} }
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": []
"timestamp": "2025-04-13T13:43:59.139683+00:00"
} }

View File

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

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 = ("hl7.fhir.r4.core", "4.0.1")
CANONICAL_PACKAGE_ID = f"{CANONICAL_PACKAGE[0]}#{CANONICAL_PACKAGE[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 = { FHIR_R4_BASE_TYPES = {
"Account", "ActivityDefinition", "AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance", "Account", "ActivityDefinition", "AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance",
"Appointment", "AppointmentResponse", "AuditEvent", "Basic", "Binary", "BiologicallyDerivedProduct", "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" return f"{sanitize_filename_part(name)}-{sanitize_filename_part(version)}.metadata.json"
def parse_package_filename(filename): def parse_package_filename(filename):
""" """Parses a standard FHIR package filename into name and version."""
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
if not filename or not filename.endswith('.tgz'): if not filename or not filename.endswith('.tgz'):
logger.debug(f"Filename '{filename}' does not end with .tgz") logger.debug(f"Filename '{filename}' does not end with .tgz")
return None, None return None, None
@ -133,20 +127,15 @@ def parse_package_filename(filename):
return name, version return name, version
else: else:
last_hyphen_index = base_name.rfind('-', 0, last_hyphen_index) 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('_', '.') name = base_name.replace('_', '.')
version = "" version = ""
return name, version return name, version
def find_and_extract_sd(tgz_path, resource_identifier): def find_and_extract_sd(tgz_path, resource_identifier):
""" """Helper to find and extract StructureDefinition json from a tgz path."""
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).
"""
sd_data = None sd_data = None
found_path = None found_path = None
logger = logging.getLogger(__name__)
if not tgz_path or not os.path.exists(tgz_path): if not tgz_path or not os.path.exists(tgz_path):
logger.error(f"File not found in find_and_extract_sd: {tgz_path}") logger.error(f"File not found in find_and_extract_sd: {tgz_path}")
return None, None return None, None
@ -371,7 +360,6 @@ def process_package_file(tgz_path):
if must_support is True: if must_support is True:
if element_id and element_path: 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_path = element_id if slice_name else element_path
ms_paths_in_this_sd.add(ms_path) ms_paths_in_this_sd.add(ms_path)
has_ms_in_this_sd = True has_ms_in_this_sd = True
@ -432,7 +420,6 @@ def process_package_file(tgz_path):
resource_type = data.get('resourceType') resource_type = data.get('resourceType')
if not resource_type: continue if not resource_type: continue
# Prioritize meta.profile matches (from oldest version for reliability)
profile_meta = data.get('meta', {}).get('profile', []) profile_meta = data.get('meta', {}).get('profile', [])
found_profile_match = False found_profile_match = False
if profile_meta and isinstance(profile_meta, list): if profile_meta and isinstance(profile_meta, list):
@ -446,11 +433,10 @@ def process_package_file(tgz_path):
break break
elif profile_url in resource_info: elif profile_url in resource_info:
associated_key = profile_url 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") logger.debug(f"Example {member.name} associated with profile {associated_key} via meta.profile")
break break
# Fallback to base type
if not found_profile_match: if not found_profile_match:
key_to_use = resource_type key_to_use = resource_type
if key_to_use not in resource_info: 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}") logger.debug(f"Example {member.name} associated with resource type {associated_key}")
referenced_types.add(resource_type) referenced_types.add(resource_type)
else: else:
# Handle non-JSON examples (from oldest version)
guessed_type = base_filename_lower.split('-')[0].capitalize() guessed_type = base_filename_lower.split('-')[0].capitalize()
guessed_profile_id = base_filename_lower.split('-')[0] guessed_profile_id = base_filename_lower.split('-')[0]
key_to_use = None key_to_use = None
@ -574,16 +559,12 @@ def process_package_file(tgz_path):
# --- Validation Functions --- # --- Validation Functions ---
def navigate_fhir_path(resource, path, extension_url=None): def navigate_fhir_path(resource, path, extension_url=None):
""" """Navigates a FHIR resource using a FHIRPath-like expression."""
Navigates a FHIR resource using a FHIRPath-like expression.
Returns the value(s) at the specified path or None if not found.
"""
if not resource or not path: if not resource or not path:
return None return None
parts = path.split('.') parts = path.split('.')
current = resource current = resource
for part in parts: for part in parts:
# Handle array indexing (e.g., element[0])
match = re.match(r'^(\w+)\[(\d+)\]$', part) match = re.match(r'^(\w+)\[(\d+)\]$', part)
if match: if match:
key, index = match.groups() key, index = match.groups()
@ -596,21 +577,16 @@ def navigate_fhir_path(resource, path, extension_url=None):
else: else:
return None return None
else: else:
# Handle regular path components
if isinstance(current, dict) and part in current: if isinstance(current, dict) and part in current:
current = current[part] current = current[part]
else: else:
return None return None
# Handle extension filtering
if extension_url and isinstance(current, list): if extension_url and isinstance(current, list):
current = [item for item in current if item.get('url') == extension_url] current = [item for item in current if item.get('url') == extension_url]
return current return current
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True): def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
""" """Validates a FHIR resource against a StructureDefinition in the specified package."""
Validates a FHIR resource against a StructureDefinition in the specified package.
Returns a dict with 'valid', 'errors', and 'warnings'.
"""
logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}") logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}")
result = {'valid': True, 'errors': [], 'warnings': []} result = {'valid': True, 'errors': [], 'warnings': []}
download_dir = _get_download_dir() 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 # Basic validation: check required elements and Must Support
elements = sd_data.get('snapshot', {}).get('element', []) elements = sd_data.get('snapshot', {}).get('element', [])
for element in elements: for element in elements:
if element.get('min', 0) > 0:
path = element.get('path') path = element.get('path')
if element.get('min', 0) > 0:
value = navigate_fhir_path(resource, path) value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not value): if value is None or (isinstance(value, list) and not value):
result['valid'] = False result['valid'] = False
result['errors'].append(f"Required element {path} missing") result['errors'].append(f"Required element {path} missing")
if element.get('mustSupport', False): if element.get('mustSupport', False):
path = element.get('path')
value = navigate_fhir_path(resource, path) value = navigate_fhir_path(resource, path)
if value is None or (isinstance(value, list) and not value): if value is None or (isinstance(value, list) and not value):
result['warnings'].append(f"Must Support element {path} missing or empty") 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 return result
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True): def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
""" """Validates a FHIR Bundle against profiles in the specified package."""
Validates a FHIR Bundle against profiles in the specified package.
Returns a dict with 'valid', 'errors', 'warnings', and per-resource 'results'.
"""
logger.debug(f"Validating bundle against {package_name}#{version}") logger.debug(f"Validating bundle against {package_name}#{version}")
result = { result = {
'valid': True, 'valid': True,
@ -675,6 +647,74 @@ def validate_bundle_against_profile(package_name, version, bundle, include_depen
return result 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 --- # --- Other Service Functions ---
def _build_package_index(download_dir): def _build_package_index(download_dir):
"""Builds an index of canonical URLs to package details from .index.json files.""" """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.""" """Loads a StructureDefinition from package details."""
if not details: if not details:
return None 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: try:
with tarfile.open(tgz_path, "r:gz") as tar: with tarfile.open(tgz_path, "r:gz") as tar:
member_path = f"package/{details['filename']}" member_path = f"package/{details['filename']}"
@ -735,7 +775,9 @@ def _load_definition(details, download_dir):
except Exception as e: except Exception as e:
logger.error(f"Failed to load definition {details['filename']} from {tgz_path}: {e}") logger.error(f"Failed to load definition {details['filename']} from {tgz_path}: {e}")
return None return None
def download_package(name, version): def download_package(name, version):
"""Downloads a single FHIR package."""
download_dir = _get_download_dir() download_dir = _get_download_dir()
if not download_dir: return None, "Download dir error" if not download_dir: return None, "Download dir error"
filename = construct_tgz_filename(name, version) filename = construct_tgz_filename(name, version)
@ -760,6 +802,7 @@ def download_package(name, version):
return None, f"File write error: {e}" return None, f"File write error: {e}"
def extract_dependencies(tgz_path): def extract_dependencies(tgz_path):
"""Extracts dependencies from package.json."""
package_json_path = "package/package.json" package_json_path = "package/package.json"
dependencies = {} dependencies = {}
error_message = None error_message = None
@ -782,18 +825,14 @@ def extract_used_types(tgz_path):
used_types = set() used_types = set()
if not tgz_path or not os.path.exists(tgz_path): if not tgz_path or not os.path.exists(tgz_path):
logger.error(f"Cannot extract used types: File not found at {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: try:
with tarfile.open(tgz_path, "r:gz") as tar: with tarfile.open(tgz_path, "r:gz") as tar:
for member in 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')): if not (member.isfile() and member.name.startswith('package/') and member.name.lower().endswith('.json')):
continue continue
# Skip metadata files
if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']: if os.path.basename(member.name).lower() in ['package.json', '.index.json', 'validation-summary.json', 'validation-oo.json']:
continue continue
fileobj = None fileobj = None
try: try:
fileobj = tar.extractfile(member) fileobj = tar.extractfile(member)
@ -801,71 +840,40 @@ def extract_used_types(tgz_path):
content_bytes = fileobj.read() content_bytes = fileobj.read()
content_string = content_bytes.decode('utf-8-sig') content_string = content_bytes.decode('utf-8-sig')
data = json.loads(content_string) data = json.loads(content_string)
if not isinstance(data, dict): continue
if not isinstance(data, dict): continue # Skip if not a valid JSON object
resource_type = data.get('resourceType') resource_type = data.get('resourceType')
if not resource_type: continue # Skip if no resourceType if not resource_type: continue
# Add the resource type itself
used_types.add(resource_type) used_types.add(resource_type)
# --- StructureDefinition Specific Extraction ---
if resource_type == 'StructureDefinition': if resource_type == 'StructureDefinition':
# Add the type this SD defines/constrains
sd_type = data.get('type') sd_type = data.get('type')
if sd_type: used_types.add(sd_type) if sd_type: used_types.add(sd_type)
# Add the base definition type if it's a profile
base_def = data.get('baseDefinition') base_def = data.get('baseDefinition')
if base_def: if base_def:
base_type = base_def.split('/')[-1] 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)
if base_type and base_type[0].isupper():
used_types.add(base_type)
# Extract types from elements (snapshot or differential)
elements = data.get('snapshot', {}).get('element', []) or data.get('differential', {}).get('element', []) elements = data.get('snapshot', {}).get('element', []) or data.get('differential', {}).get('element', [])
for element in elements: for element in elements:
if isinstance(element, dict) and 'type' in element: if isinstance(element, dict) and 'type' in element:
for t in element.get('type', []): for t in element.get('type', []):
# Add code (element type)
code = t.get('code') code = t.get('code')
if code and code[0].isupper(): used_types.add(code) if code and code[0].isupper(): used_types.add(code)
# Add targetProfile types (Reference targets)
for profile_uri in t.get('targetProfile', []): for profile_uri in t.get('targetProfile', []):
if profile_uri: if profile_uri:
profile_type = profile_uri.split('/')[-1] profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type) 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: else:
# Look for meta.profile for referenced profiles -> add profile type
profiles = data.get('meta', {}).get('profile', []) profiles = data.get('meta', {}).get('profile', [])
for profile_uri in profiles: for profile_uri in profiles:
if profile_uri: if profile_uri:
profile_type = profile_uri.split('/')[-1] profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type) 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': if resource_type == 'ValueSet':
for include in data.get('compose', {}).get('include', []): for include in data.get('compose', {}).get('include', []):
system = include.get('system') 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/'): if system and system.startswith('http://hl7.org/fhir/'):
type_name = system.split('/')[-1] 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'):
if type_name and type_name[0].isupper() and not type_name.startswith('sid'): # Avoid things like sid/us-ssn
used_types.add(type_name) 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': if resource_type == 'CapabilityStatement':
for rest_item in data.get('rest', []): for rest_item in data.get('rest', []):
for resource_item in rest_item.get('resource', []): for resource_item in rest_item.get('resource', []):
@ -875,60 +883,36 @@ def extract_used_types(tgz_path):
if profile_uri: if profile_uri:
profile_type = profile_uri.split('/')[-1] profile_type = profile_uri.split('/')[-1]
if profile_type and profile_type[0].isupper(): used_types.add(profile_type) 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: 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: 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: 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: finally:
if fileobj: if fileobj:
fileobj.close() fileobj.close()
except tarfile.ReadError as e: except tarfile.ReadError as e:
logger.error(f"Tar ReadError extracting used types from {tgz_path}: {e}") logger.error(f"Tar ReadError extracting used types from {tgz_path}: {e}")
except tarfile.TarError as e: except tarfile.TarError as e:
logger.error(f"TarError extracting used types from {tgz_path}: {e}") logger.error(f"TarError extracting used types from {tgz_path}: {e}")
except FileNotFoundError: 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: except Exception as e:
logger.error(f"Error extracting used types from {tgz_path}: {e}", exc_info=True) logger.error(f"Error extracting used types from {tgz_path}: {e}", exc_info=True)
core_non_resource_types = {
# Filter out potential primitives or base types that aren't resources? 'string', 'boolean', 'integer', 'decimal', 'uri', 'url', 'canonical', 'base64Binary', 'instant',
# E.g., 'string', 'boolean', 'Element', 'BackboneElement', 'Resource' 'date', 'dateTime', 'time', 'code', 'oid', 'id', 'markdown', 'unsignedInt', 'positiveInt', 'xhtml',
core_non_resource_types = {'string', 'boolean', 'integer', 'decimal', 'uri', 'url', 'canonical', 'Element', 'BackboneElement', 'Resource', 'DomainResource', 'DataType'
'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()} 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}") logger.debug(f"Extracted used types from {os.path.basename(tgz_path)}: {final_used_types}")
return final_used_types return final_used_types
def map_types_to_packages(used_types, all_dependencies, download_dir): 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 = {} type_to_package = {}
processed_types = set() processed_types = set()
# Load .index.json from each package to map types
for (pkg_name, pkg_version), _ in all_dependencies.items(): for (pkg_name, pkg_version), _ in all_dependencies.items():
tgz_filename = construct_tgz_filename(pkg_name, pkg_version) tgz_filename = construct_tgz_filename(pkg_name, pkg_version)
tgz_path = os.path.join(download_dir, tgz_filename) 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: if sd_name in used_types:
type_to_package[sd_name] = (pkg_name, pkg_version) type_to_package[sd_name] = (pkg_name, pkg_version)
processed_types.add(sd_name) 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: except Exception as e:
logger.warning(f"Failed to process .index.json for {pkg_name}#{pkg_version}: {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 t in used_types - processed_types:
for (pkg_name, pkg_version), _ in all_dependencies.items(): for (pkg_name, pkg_version), _ in all_dependencies.items():
if t.lower() in pkg_name.lower(): if t.lower() in pkg_name.lower():
type_to_package[t] = (pkg_name, pkg_version) type_to_package[t] = (pkg_name, pkg_version)
processed_types.add(t) 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 break
# Final fallback: Map remaining types to canonical package
canonical_name, canonical_version = CANONICAL_PACKAGE canonical_name, canonical_version = CANONICAL_PACKAGE
for t in used_types - processed_types: for t in used_types - processed_types:
type_to_package[t] = CANONICAL_PACKAGE type_to_package[t] = CANONICAL_PACKAGE
logger.debug(f"Fallback: Mapped type '{t}' to canonical package {canonical_name}#{canonical_version}") 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}") logger.debug(f"Final type-to-package mapping: {type_to_package}")
return type_to_package return type_to_package
def import_package_and_dependencies(initial_name, initial_version, dependency_mode='recursive'): 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}") logger.info(f"Starting import for {initial_name}#{initial_version} with dependency_mode={dependency_mode}")
download_dir = _get_download_dir() download_dir = _get_download_dir()
if not download_dir: if not download_dir:
@ -994,17 +973,14 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
while pending_queue: while pending_queue:
name, version = pending_queue.pop(0) name, version = pending_queue.pop(0)
package_id_tuple = (name, version) package_id_tuple = (name, version)
if package_id_tuple in results['processed']: if package_id_tuple in results['processed']:
logger.debug(f"Skipping already processed package: {name}#{version}") logger.debug(f"Skipping already processed package: {name}#{version}")
continue continue
logger.info(f"Processing package from queue: {name}#{version}") logger.info(f"Processing package from queue: {name}#{version}")
save_path, dl_error = download_package(name, version) save_path, dl_error = download_package(name, version)
if dl_error: if dl_error:
results['errors'].append(f"Download failed for {name}#{version}: {dl_error}") results['errors'].append(f"Download failed for {name}#{version}: {dl_error}")
continue continue
results['downloaded'][package_id_tuple] = save_path results['downloaded'][package_id_tuple] = save_path
dependencies, dep_error = extract_dependencies(save_path) dependencies, dep_error = extract_dependencies(save_path)
if dep_error: 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['errors'].append(f"Dependency extraction returned critical error for {name}#{version}.")
results['processed'].add(package_id_tuple) results['processed'].add(package_id_tuple)
continue continue
results['all_dependencies'][package_id_tuple] = dependencies results['all_dependencies'][package_id_tuple] = dependencies
results['processed'].add(package_id_tuple) results['processed'].add(package_id_tuple)
current_package_deps = [] current_package_deps = []
for dep_name, dep_version in dependencies.items(): for dep_name, dep_version in dependencies.items():
if isinstance(dep_name, str) and isinstance(dep_version, str) and dep_name and dep_version: 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: if dep_tuple not in all_found_dependencies:
all_found_dependencies.add(dep_tuple) all_found_dependencies.add(dep_tuple)
results['dependencies'].append({"name": dep_name, "version": dep_version}) results['dependencies'].append({"name": dep_name, "version": dep_version})
if dep_tuple not in queued_or_processed_lookup: if dep_tuple not in queued_or_processed_lookup:
should_queue = False should_queue = False
if dependency_mode == 'recursive': if dependency_mode == 'recursive':
should_queue = True should_queue = True
elif dependency_mode == 'patch-canonical' and dep_tuple == CANONICAL_PACKAGE: elif dependency_mode == 'patch-canonical' and dep_tuple == CANONICAL_PACKAGE:
should_queue = True should_queue = True
# Tree-shaking dependencies are handled post-loop for the initial package
if should_queue: if should_queue:
logger.debug(f"Adding dependency to queue ({dependency_mode}): {dep_name}#{dep_version}") logger.debug(f"Adding dependency to queue ({dependency_mode}): {dep_name}#{dep_version}")
pending_queue.append(dep_tuple) pending_queue.append(dep_tuple)
queued_or_processed_lookup.add(dep_tuple) queued_or_processed_lookup.add(dep_tuple)
save_package_metadata(name, version, dependency_mode, current_package_deps) 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): if dependency_mode == 'tree-shaking' and package_id_tuple == (initial_name, initial_version):
logger.info(f"Performing tree-shaking for {initial_name}#{initial_version}") logger.info(f"Performing tree-shaking for {initial_name}#{initial_version}")
used_types = extract_used_types(save_path) 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: if CANONICAL_PACKAGE not in tree_shaken_deps:
tree_shaken_deps.add(CANONICAL_PACKAGE) tree_shaken_deps.add(CANONICAL_PACKAGE)
logger.debug(f"Ensuring canonical package {CANONICAL_PACKAGE} for tree-shaking") logger.debug(f"Ensuring canonical package {CANONICAL_PACKAGE} for tree-shaking")
for dep_tuple in tree_shaken_deps: for dep_tuple in tree_shaken_deps:
if dep_tuple not in queued_or_processed_lookup: if dep_tuple not in queued_or_processed_lookup:
logger.info(f"Queueing tree-shaken dependency: {dep_tuple[0]}#{dep_tuple[1]}") logger.info(f"Queueing tree-shaken dependency: {dep_tuple[0]}#{dep_tuple[1]}")
pending_queue.append(dep_tuple) pending_queue.append(dep_tuple)
queued_or_processed_lookup.add(dep_tuple) queued_or_processed_lookup.add(dep_tuple)
results['dependencies'] = [{"name": d[0], "version": d[1]} for d in all_found_dependencies] 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'])}") logger.info(f"Import finished for {initial_name}#{initial_version}. Processed: {len(results['processed'])}, Downloaded: {len(results['downloaded'])}, Errors: {len(results['errors'])}")
return results return results
# --- Standalone Test ---
# --- Standalone Test/Example Usage ---
if __name__ == '__main__': if __name__ == '__main__':
logger.info("Running services.py directly for testing.") logger.info("Running services.py directly for testing.")
class MockFlask: class MockFlask:

View File

@ -52,7 +52,6 @@
<tbody> <tbody>
{% for pkg in packages %} {% for pkg in packages %}
{% set is_processed = (pkg.name, pkg.version) in processed_ids %} {% 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 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 '' %} {% 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 %}"> <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-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
<div class="card-body"> <div class="card-body">
{% if processed_list %} {% 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> <p class="mb-2 small text-muted">Resource Types in the list will be both Profile and Base Type:</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
@ -126,7 +128,8 @@
{% if types_info %} {% if types_info %}
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for type_info in types_info %} {% 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 %} {% endfor %}
</div> </div>
{% else %} {% else %}

View File

@ -84,24 +84,17 @@
<a href="#" class="resource-type-link text-decoration-none" <a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}" data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}" 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 }}"> aria-label="View structure for {{ type_info.name }}">
{# --- Badge Logic --- #}
{% if type_info.must_support %} {% 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) %} {% 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">{{ type_info.name }}</span>
<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>
{% else %} {% 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> <span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
{% endif %} {% endif %}
{% else %} {% else %}
{# Default light badge if no Must Support #}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span> <span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %} {% endif %}
{# --- End Badge Logic --- #}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
@ -115,11 +108,10 @@
<a href="#" class="resource-type-link text-decoration-none" <a href="#" class="resource-type-link text-decoration-none"
data-package-name="{{ processed_ig.package_name }}" data-package-name="{{ processed_ig.package_name }}"
data-package-version="{{ processed_ig.version }}" 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 }}"> 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 %} {% 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 %} {% else %}
<span class="badge bg-light text-dark border">{{ type_info.name }}</span> <span class="badge bg-light text-dark border">{{ type_info.name }}</span>
{% endif %} {% endif %}
@ -193,7 +185,8 @@
{% else %} {% else %}
<div class="alert alert-warning" role="alert">Processed IG details not available.</div> <div class="alert alert-warning" role="alert">Processed IG details not available.</div>
{% endif %} {% 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>const examplesData = {{ examples_by_type | tojson | safe }};</script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -220,15 +213,6 @@ document.addEventListener('DOMContentLoaded', function() {
const structureBaseUrl = "{{ url_for('get_structure_definition') }}"; const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
const exampleBaseUrl = "{{ url_for('get_example_content') }}"; const exampleBaseUrl = "{{ url_for('get_example_content') }}";
// 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 => { document.querySelectorAll('.resource-type-list').forEach(container => {
container.addEventListener('click', function(event) { container.addEventListener('click', function(event) {
const link = event.target.closest('.resource-type-link'); const link = event.target.closest('.resource-type-link');
@ -238,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function() {
const pkgName = link.dataset.packageName; const pkgName = link.dataset.packageName;
const pkgVersion = link.dataset.packageVersion; 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) { if (!pkgName || !pkgVersion || !resourceType) {
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType }); console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
return; return;
@ -248,42 +232,35 @@ document.addEventListener('DOMContentLoaded', function() {
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`; const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
console.log("Fetching structure from:", structureFetchUrl); console.log("Fetching structure from:", structureFetchUrl);
// Update titles and reset displays
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`; structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`; rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
structureDisplay.innerHTML = ''; // Clear previous structure structureDisplay.innerHTML = '';
rawStructureContent.textContent = ''; // Clear previous raw structure rawStructureContent.textContent = '';
structureLoading.style.display = 'block'; structureLoading.style.display = 'block';
rawStructureLoading.style.display = 'block'; rawStructureLoading.style.display = 'block';
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
structureDisplayWrapper.style.display = 'block'; // Show structure section structureDisplayWrapper.style.display = 'block';
rawStructureWrapper.style.display = 'block'; // Show raw structure section rawStructureWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially exampleDisplayWrapper.style.display = 'none';
// Fetch Structure Definition
fetch(structureFetchUrl) fetch(structureFetchUrl)
.then(response => { .then(response => {
console.log("Structure fetch response status:", response.status); 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 })); return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
}) })
.then(result => { .then(result => {
if (!result.ok) { if (!result.ok) {
// Throw error using message from JSON body if available
throw new Error(result.data.error || `HTTP error ${result.status}`); throw new Error(result.data.error || `HTTP error ${result.status}`);
} }
console.log("Structure data received:", result.data); console.log("Structure data received:", result.data);
// Display fallback message if needed
if (result.data.fallback_used) { if (result.data.fallback_used) {
structureFallbackMessage.style.display = 'block'; structureFallbackMessage.style.display = 'block';
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`; structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`;
} else { } else {
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
} }
// Render the tree and raw JSON renderStructureTree(result.data.elements, result.data.must_support_paths || [], resourceType);
renderStructureTree(result.data.elements, result.data.must_support_paths || []); rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
rawStructureContent.textContent = JSON.stringify(result.data, null, 2); // Pretty print raw SD
// Populate examples for the clicked resource/profile ID
populateExampleSelector(resourceType); populateExampleSelector(resourceType);
}) })
.catch(error => { .catch(error => {
@ -291,108 +268,96 @@ document.addEventListener('DOMContentLoaded', function() {
structureFallbackMessage.style.display = 'none'; structureFallbackMessage.style.display = 'none';
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`; structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
rawStructureContent.textContent = `Error loading structure: ${error.message}`; rawStructureContent.textContent = `Error loading structure: ${error.message}`;
// Still try to show examples if structure fails
populateExampleSelector(resourceType); populateExampleSelector(resourceType);
}) })
.finally(() => { .finally(() => {
// Hide loading spinners
structureLoading.style.display = 'none'; structureLoading.style.display = 'none';
rawStructureLoading.style.display = 'none'; rawStructureLoading.style.display = 'none';
}); });
}); });
}); });
// Function to populate the example dropdown
function populateExampleSelector(resourceOrProfileIdentifier) { function populateExampleSelector(resourceOrProfileIdentifier) {
console.log("Populating examples for:", resourceOrProfileIdentifier); console.log("Populating examples for:", resourceOrProfileIdentifier);
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier; // Update example card title exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
const availableExamples = examplesData[resourceOrProfileIdentifier] || []; const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
console.log("Available examples:", availableExamples); console.log("Available examples:", availableExamples);
// Reset example section exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>'; // Clear previous options exampleContentWrapper.style.display = 'none';
exampleContentWrapper.style.display = 'none'; // Hide content area
exampleFilename.textContent = ''; exampleFilename.textContent = '';
exampleContentRaw.textContent = ''; exampleContentRaw.textContent = '';
exampleContentJson.textContent = ''; exampleContentJson.textContent = '';
if (availableExamples.length > 0) { if (availableExamples.length > 0) {
// Add new options
availableExamples.forEach(filePath => { availableExamples.forEach(filePath => {
const filename = filePath.split('/').pop(); // Get filename from path const filename = filePath.split('/').pop();
const option = document.createElement('option'); const option = document.createElement('option');
option.value = filePath; option.value = filePath;
option.textContent = filename; option.textContent = filename;
exampleSelect.appendChild(option); exampleSelect.appendChild(option);
}); });
exampleSelectorWrapper.style.display = 'block'; // Show dropdown exampleSelectorWrapper.style.display = 'block';
exampleDisplayWrapper.style.display = 'block'; // Show example card exampleDisplayWrapper.style.display = 'block';
} else { } else {
exampleSelectorWrapper.style.display = 'none'; // Hide dropdown if no examples exampleSelectorWrapper.style.display = 'none';
exampleDisplayWrapper.style.display = 'none'; // Hide example card if no examples exampleDisplayWrapper.style.display = 'none';
} }
} }
// Event listener for example selection change
if (exampleSelect) { if (exampleSelect) {
exampleSelect.addEventListener('change', function(event) { exampleSelect.addEventListener('change', function(event) {
const selectedFilePath = this.value; const selectedFilePath = this.value;
console.log("Selected example file:", selectedFilePath); console.log("Selected example file:", selectedFilePath);
// Reset example content areas
exampleContentRaw.textContent = ''; exampleContentRaw.textContent = '';
exampleContentJson.textContent = ''; exampleContentJson.textContent = '';
exampleFilename.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({ const exampleParams = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}", package_name: "{{ processed_ig.package_name }}",
package_version: "{{ processed_ig.version }}", package_version: "{{ processed_ig.version }}",
filename: selectedFilePath filename: selectedFilePath
}); });
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`; const exampleFetchUrl = `${exampleBaseUrl}?${structureParams.toString()}`;
console.log("Fetching example from:", exampleFetchUrl); console.log("Fetching example from:", exampleFetchUrl);
exampleLoading.style.display = 'block'; // Show loading spinner exampleLoading.style.display = 'block';
// Fetch example content
fetch(exampleFetchUrl) fetch(exampleFetchUrl)
.then(response => { .then(response => {
console.log("Example fetch response status:", response.status); console.log("Example fetch response status:", response.status);
if (!response.ok) { 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().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
} }
return response.text(); // Get content as text return response.text();
}) })
.then(content => { .then(content => {
console.log("Example content received."); // Avoid logging potentially large content console.log("Example content received.");
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`; exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
exampleContentRaw.textContent = content; // Display raw content exampleContentRaw.textContent = content;
// Try to parse and pretty-print if it's JSON
try { try {
const jsonContent = JSON.parse(content); const jsonContent = JSON.parse(content);
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2); exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
} catch (e) { } catch (e) {
console.warn("Example content is not valid JSON:", e.message); 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 => { .catch(error => {
console.error("Error fetching example:", error); console.error("Error fetching example:", error);
exampleFilename.textContent = 'Error loading example'; exampleFilename.textContent = 'Error loading example';
exampleContentRaw.textContent = `Error: ${error.message}`; exampleContentRaw.textContent = `Error: ${error.message}`;
exampleContentJson.textContent = `Error: ${error.message}`; exampleContentJson.textContent = `Error: ${error.message}`;
exampleContentWrapper.style.display = 'block'; // Show error in content area exampleContentWrapper.style.display = 'block';
}) })
.finally(() => { .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) { function buildTreeData(elements) {
const treeRoot = { children: {}, element: null, name: 'Root' }; const treeRoot = { children: {}, element: null, name: 'Root' };
const nodeMap = { 'Root': treeRoot }; const nodeMap = { 'Root': treeRoot };
@ -415,14 +380,15 @@ document.addEventListener('DOMContentLoaded', function() {
let parentNode = treeRoot; let parentNode = treeRoot;
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; let part = parts[i];
currentPath = i === 0 ? part : `${currentPath}.${part}`; 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 = isChoiceElement ? part : cleanPart;
let nodeKey = part; if (sliceName && i === parts.length - 1) {
if (part === 'extension' && i === parts.length - 1 && sliceName) { nodeKey = sliceName;
nodeKey = `${part}:${sliceName}`; currentPath = id || currentPath;
currentPath = id || currentPath; // Use id for precise matching in extensions
} }
if (!nodeMap[currentPath]) { if (!nodeMap[currentPath]) {
@ -432,17 +398,68 @@ document.addEventListener('DOMContentLoaded', function() {
name: nodeKey, name: nodeKey,
path: currentPath 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 = nodeMap[parentPath] || treeRoot;
parentNode.children[nodeKey] = newNode; parentNode.children[nodeKey] = newNode;
nodeMap[currentPath] = newNode; nodeMap[currentPath] = newNode;
console.log(`Created node: path=${currentPath}, name=${nodeKey}`); 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) { if (i === parts.length - 1) {
const targetNode = nodeMap[currentPath]; const targetNode = nodeMap[currentPath];
targetNode.element = el; targetNode.element = el;
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`); 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]; parentNode = nodeMap[currentPath];
@ -454,8 +471,7 @@ document.addEventListener('DOMContentLoaded', function() {
return treeData; return treeData;
} }
// Function to render a single node (and its children recursively) as an <li> function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
function renderNodeAsLi(node, mustSupportPathsSet, level = 0) {
if (!node || !node.element) { if (!node || !node.element) {
console.warn("Skipping render for invalid node:", node); console.warn("Skipping render for invalid node:", node);
return ''; return '';
@ -470,24 +486,11 @@ document.addEventListener('DOMContentLoaded', function() {
const definition = el.definition || ''; const definition = el.definition || '';
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`); 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)); let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
if (!isMustSupport && path.startsWith('Extension.extension')) { let isOptional = min == 0 && isMustSupport && el.type && el.type.some(t => t.code === 'Extension') && path.includes('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}`);
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2'; const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
const mustSupportDisplay = isMustSupport ? '<i class="bi bi-check-circle-fill 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 hasChildren = Object.keys(node.children).length > 0;
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`; const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
const padding = level * 20; const padding = level * 20;
@ -510,15 +513,15 @@ document.addEventListener('DOMContentLoaded', function() {
let childrenHtml = ''; let childrenHtml = '';
if (hasChildren) { 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 => { 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>`; childrenHtml += `</ul>`;
} }
let itemHtml = `<li class="${liClass}">`; 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 += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`; itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
if (hasChildren) { if (hasChildren) {
@ -533,11 +536,11 @@ document.addEventListener('DOMContentLoaded', function() {
let descriptionTooltipAttrs = ''; let descriptionTooltipAttrs = '';
if (definition) { if (definition) {
const escapedDefinition = definition const escapedDefinition = definition
.replace(/&/g, '&amp;') .replace(/&/g, '&')
.replace(/</g, '&lt;') .replace(/</g, '<')
.replace(/>/g, '&gt;') .replace(/>/g, '>')
.replace(/"/g, '&quot;') .replace(/"/g, '"')
.replace(/'/g, '&#39;') .replace(/'/g, '\'')
.replace(/\n/g, ' '); .replace(/\n/g, ' ');
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`; descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
} }
@ -554,19 +557,12 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// Filter must_support_paths to avoid generic extension paths const mustSupportPathsSet = new Set(mustSupportPaths);
const validMustSupportPaths = mustSupportPaths.filter(path => {
if (path.includes('.extension') && !path.includes(':')) {
return false; // Exclude generic Patient.extension
}
return true;
});
const mustSupportPathsSet = new Set(validMustSupportPaths);
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet); console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
let html = '<p><small>' + let html = '<p><small>' +
'<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (yellow row)<br>' + '<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (yellow row)<br>' +
'<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional (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 += `<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">'; 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 => { structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
const collapseTargetId = toggleEl.getAttribute('data-bs-target'); const collapseId = toggleEl.getAttribute('data-collapse-id');
if (!collapseTargetId) return; if (!collapseId) {
const collapseEl = document.querySelector(collapseTargetId); 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'); const toggleIcon = toggleEl.querySelector('.toggle-icon');
if (!toggleIcon) {
console.warn("No toggle icon for:", collapseId);
return;
}
if (collapseEl && toggleIcon) { console.log("Initializing collapse for:", collapseId);
collapseEl.classList.remove('show'); // Initialize Bootstrap Collapse
toggleIcon.classList.remove('bi-chevron-down'); const bsCollapse = new bootstrap.Collapse(collapseEl, {
toggleIcon.classList.add('bi-chevron-right'); toggle: false
});
// Custom click handler with debounce
toggleEl.addEventListener('click', event => { toggleEl.addEventListener('click', event => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const isCurrentlyShown = collapseEl.classList.contains('show'); const now = Date.now();
toggleIcon.classList.toggle('bi-chevron-right', isCurrentlyShown); if (now - lastClickTime < debounceDelay) {
toggleIcon.classList.toggle('bi-chevron-down', !isCurrentlyShown); 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.remove('bi-chevron-right');
toggleIcon.classList.add('bi-chevron-down'); 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.remove('bi-chevron-down');
toggleIcon.classList.add('bi-chevron-right'); 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) { } catch (e) {
console.error("JavaScript error in cp_view_processed_ig.html:", 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>). Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
</small> </small>
<div class="mt-2"> <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> <option value="">-- Select a Package --</option>
{% if packages %} {% if packages %}
{% for pkg in packages %} {% for pkg in packages %}
<option value="{{ pkg.id }}">{{ pkg.text }}</option> <option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
{% endfor %} {% endfor %}
{% else %} {% else %}
<option value="" disabled>No packages available</option> <option value="" disabled>No packages available</option>
@ -39,14 +39,14 @@
</select> </select>
</div> </div>
<label for="{{ form.package_name.id }}" class="form-label">Package Name</label> <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 %} {% for error in form.package_name.errors %}
<div class="text-danger">{{ error }}</div> <div class="text-danger">{{ error }}</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.version.id }}" class="form-label">Package Version</label> <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 %} {% for error in form.version.errors %}
<div class="text-danger">{{ error }}</div> <div class="text-danger">{{ error }}</div>
{% endfor %} {% endfor %}
@ -125,21 +125,31 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('validationForm');
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
const packageSelect = document.getElementById('packageSelect'); const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}'); const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.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 // Update package_name and version fields from dropdown
function updatePackageFields(select) { function updatePackageFields(select) {
const value = select.value; const value = select.value;
console.log('Selected package:', value); console.log('Selected package:', value);
if (!packageNameInput || !versionInput) {
console.error('Input fields not found');
return;
}
if (value) { if (value) {
const parts = value.split('#'); const [name, version] = value.split('#');
if (parts.length === 2 && parts[0] && parts[1]) { if (name && version) {
packageNameInput.value = parts[0]; packageNameInput.value = name;
versionInput.value = parts[1]; versionInput.value = version;
console.log('Updated fields:', { packageName: name, version });
} else { } else {
console.warn('Invalid package format:', value); console.warn('Invalid package format:', value);
packageNameInput.value = ''; packageNameInput.value = '';
@ -148,19 +158,32 @@ document.addEventListener('DOMContentLoaded', function() {
} else { } else {
packageNameInput.value = ''; packageNameInput.value = '';
versionInput.value = ''; versionInput.value = '';
console.log('Cleared fields: no package selected');
} }
} }
// Initialize dropdown with current form values // Initialize dropdown based on form values
if (packageNameInput.value && versionInput.value) { if (packageNameInput && versionInput && packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`; const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const option = packageSelect.querySelector(`option[value="${currentValue}"]`); if (packageSelect.querySelector(`option[value="${currentValue}"]`)) {
if (option) {
packageSelect.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 // Client-side JSON validation preview
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
if (sampleInput) {
sampleInput.addEventListener('input', function() { sampleInput.addEventListener('input', function() {
try { try {
JSON.parse(this.value); JSON.parse(this.value);
@ -171,9 +194,7 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.add('is-invalid'); 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> </script>
{% endblock %} {% endblock %}