V2.0 release.

Hapi Config Page.
retrieve bundles from server
split bundles to resources.
new upload page, and Package Registry CACHE
This commit is contained in:
Joshua Hare 2025-05-04 22:55:36 +10:00
parent cd06b1b216
commit ca8668e5e5
38 changed files with 107659 additions and 630 deletions

View File

@ -29,6 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py . COPY app.py .
COPY services.py . COPY services.py .
COPY forms.py . COPY forms.py .
COPY package.py .
COPY templates/ templates/ COPY templates/ templates/
COPY static/ static/ COPY static/ static/
COPY tests/ tests/ COPY tests/ tests/

View File

@ -148,4 +148,4 @@ FHIRVINEs core functionality (OAuth2 proxy, app registration) will be integra
- **Route Conflicts**: Ensure no overlapping routes between FHIRFLARE and FHIRVINE. - **Route Conflicts**: Ensure no overlapping routes between FHIRFLARE and FHIRVINE.
- **Database Issues**: Verify `SQLALCHEMY_DATABASE_URI` points to the same database. - **Database Issues**: Verify `SQLALCHEMY_DATABASE_URI` points to the same database.
- **Logs**: Check `flask run` logs for errors...... - **Logs**: Check `flask run` logs for errors.

1394
app.py

File diff suppressed because it is too large Load Diff

View File

@ -16,5 +16,5 @@ services:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_ENV=development - FLASK_ENV=development
- NODE_PATH=/usr/lib/node_modules - NODE_PATH=/usr/lib/node_modules
- APP_MODE=standalone - APP_MODE=lite
command: supervisord -c /etc/supervisord.conf command: supervisord -c /etc/supervisord.conf

View File

@ -1,15 +1,68 @@
# forms.py # forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
from flask import request # Import request for file validation in FSHConverterForm from flask import request # Import request for file validation in FSHConverterForm
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import re import re
import logging # Import logging import logging # Import logging
import os
logger = logging.getLogger(__name__) # Setup logger if needed elsewhere logger = logging.getLogger(__name__) # Setup logger if needed elsewhere
# Existing form classes (IgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm) remain unchanged
# Only providing RetrieveSplitDataForm
class RetrieveSplitDataForm(FlaskForm):
"""Form for retrieving FHIR bundles and splitting them into individual resources."""
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
validate_references = BooleanField('Fetch Referenced Resources', default=False, # Changed label slightly
description="If checked, fetches resources referenced by the initial bundles.")
# --- NEW FIELD ---
fetch_reference_bundles = BooleanField('Fetch Full Reference Bundles (instead of individual resources)', default=False,
description="Requires 'Fetch Referenced Resources'. Fetches e.g. /Patient instead of Patient/id for each reference.",
render_kw={'data-dependency': 'validate_references'}) # Add data attribute for JS
# --- END NEW FIELD ---
split_bundle_zip = FileField('Upload Bundles to Split (ZIP)', validators=[Optional()],
render_kw={'accept': '.zip'})
submit_retrieve = SubmitField('Retrieve Bundles')
submit_split = SubmitField('Split Bundles')
def validate(self, extra_validators=None):
"""Custom validation for RetrieveSplitDataForm."""
if not super().validate(extra_validators):
return False
# --- NEW VALIDATION LOGIC ---
# Ensure fetch_reference_bundles is only checked if validate_references is also checked
if self.fetch_reference_bundles.data and not self.validate_references.data:
self.fetch_reference_bundles.errors.append('Cannot fetch full reference bundles unless "Fetch Referenced Resources" is also checked.')
return False
# --- END NEW VALIDATION LOGIC ---
# Validate based on which submit button was pressed
if self.submit_retrieve.data:
# No specific validation needed here now, handled by URL validator and JS
pass
elif self.submit_split.data:
# Need to check bundle source radio button selection in backend/JS,
# but validate file if 'upload' is selected.
# This validation might need refinement based on how source is handled.
# Assuming 'split_bundle_zip' is only required if 'upload' source is chosen.
pass # Basic validation done by Optional() and file type checks below
# Validate file uploads (keep existing)
if self.split_bundle_zip.data:
if not self.split_bundle_zip.data.filename.lower().endswith('.zip'):
self.split_bundle_zip.errors.append('File must be a ZIP file.')
return False
return True
# Existing forms (IgImportForm, ValidationForm) remain unchanged # Existing forms (IgImportForm, ValidationForm) remain unchanged
class IgImportForm(FlaskForm): class IgImportForm(FlaskForm):
"""Form for importing Implementation Guides.""" """Form for importing Implementation Guides."""

Binary file not shown.

View File

@ -18,5 +18,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:12.567847+00:00" "timestamp": "2025-05-04T12:29:17.475734+00:00"
} }

View File

@ -30,5 +30,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:55:42.789700+00:00" "timestamp": "2025-05-04T12:29:15.067826+00:00"
} }

View File

@ -5,5 +5,5 @@
"imported_dependencies": [], "imported_dependencies": [],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:55:57.375318+00:00" "timestamp": "2025-05-04T12:29:16.477868+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:08.781015+00:00" "timestamp": "2025-05-04T12:29:17.363719+00:00"
} }

View File

@ -18,5 +18,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:16.763567+00:00" "timestamp": "2025-05-04T12:29:17.590266+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:23.123899+00:00" "timestamp": "2025-05-04T12:29:18.256800+00:00"
} }

View File

@ -14,5 +14,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:14.378183+00:00" "timestamp": "2025-05-04T12:29:17.529611+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:21.577007+00:00" "timestamp": "2025-05-04T12:29:18.216757+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-30T10:56:04.853495+00:00" "timestamp": "2025-05-04T12:29:17.148041+00:00"
} }

View File

@ -1,6 +1,6 @@
#FileLock #FileLock
#Wed Apr 30 12:04:24 UTC 2025 #Sun May 04 12:29:20 UTC 2025
server=172.19.0.2\:43507 server=172.18.0.2\:34351
hostName=999cce957cc1 hostName=1913c9e2ec9b
method=file method=file
id=196869576a9d4c77e61cf01b462a28e4b182a3e00bd id=1969b45b76c42f20115290bfabb203a60dc75365e9d

Binary file not shown.

File diff suppressed because it is too large Load Diff

123
package.py Normal file
View File

@ -0,0 +1,123 @@
from flask import Blueprint, jsonify, current_app, render_template
import os
import tarfile
import json
from datetime import datetime
import time
from services import pkg_version, safe_parse_version
package_bp = Blueprint('package', __name__)
@package_bp.route('/logs/<name>')
def logs(name):
"""
Fetch logs for a package, listing each version with its publication date.
Args:
name (str): The name of the package.
Returns:
Rendered template with logs or an error message.
"""
try:
in_memory_cache = current_app.config.get('MANUAL_PACKAGE_CACHE', [])
if not in_memory_cache:
current_app.logger.error(f"No in-memory cache found for package logs: {name}")
return "<p class='text-muted'>Package cache not found.</p>"
package_data = next((pkg for pkg in in_memory_cache if isinstance(pkg, dict) and pkg.get('name', '').lower() == name.lower()), None)
if not package_data:
current_app.logger.error(f"Package not found in cache: {name}")
return "<p class='text-muted'>Package not found.</p>"
# Get the versions list with pubDate
versions = package_data.get('all_versions', [])
if not versions:
current_app.logger.warning(f"No versions found for package: {name}. Package data: {package_data}")
return "<p class='text-muted'>No version history found for this package.</p>"
current_app.logger.debug(f"Found {len(versions)} versions for package {name}: {versions[:5]}...")
logs = []
now = time.time()
for version_info in versions:
if not isinstance(version_info, dict):
current_app.logger.warning(f"Invalid version info for {name}: {version_info}")
continue
version = version_info.get('version', '')
pub_date_str = version_info.get('pubDate', '')
if not version or not pub_date_str:
current_app.logger.warning(f"Skipping version info with missing version or pubDate: {version_info}")
continue
# Parse pubDate and calculate "when"
when = "Unknown"
try:
pub_date = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
pub_time = pub_date.timestamp()
time_diff = now - pub_time
days_ago = int(time_diff / 86400)
if days_ago < 1:
hours_ago = int(time_diff / 3600)
if hours_ago < 1:
minutes_ago = int(time_diff / 60)
when = f"{minutes_ago} minute{'s' if minutes_ago != 1 else ''} ago"
else:
when = f"{hours_ago} hour{'s' if hours_ago != 1 else ''} ago"
else:
when = f"{days_ago} day{'s' if days_ago != 1 else ''} ago"
except ValueError as e:
current_app.logger.warning(f"Failed to parse pubDate '{pub_date_str}' for version {version}: {e}")
logs.append({
"version": version,
"pubDate": pub_date_str,
"when": when
})
if not logs:
current_app.logger.warning(f"No valid version entries with pubDate for package: {name}")
return "<p class='text-muted'>No version history found for this package.</p>"
# Sort logs by version number (newest first)
logs.sort(key=lambda x: safe_parse_version(x.get('version', '0.0.0a0')), reverse=True)
current_app.logger.debug(f"Rendering logs for {name} with {len(logs)} entries")
return render_template('package.logs.html', logs=logs)
except Exception as e:
current_app.logger.error(f"Error in logs endpoint for {name}: {str(e)}", exc_info=True)
return "<p class='text-danger'>Error loading version history.</p>", 500
@package_bp.route('/dependents/<name>')
def dependents(name):
"""
HTMX endpoint to fetch packages that depend on the current package.
Returns an HTML fragment with a table of dependent packages.
"""
in_memory_cache = current_app.config.get('MANUAL_PACKAGE_CACHE', [])
package_data = next((pkg for pkg in in_memory_cache if isinstance(pkg, dict) and pkg.get('name', '').lower() == name.lower()), None)
if not package_data:
return "<p class='text-danger'>Package not found.</p>"
# Find dependents: packages whose dependencies include the current package
dependents = []
for pkg in in_memory_cache:
if not isinstance(pkg, dict):
continue
dependencies = pkg.get('dependencies', [])
for dep in dependencies:
dep_name = dep.get('name', '')
if dep_name.lower() == name.lower():
dependents.append({
"name": pkg.get('name', 'Unknown'),
"version": pkg.get('latest_absolute_version', 'N/A'),
"author": pkg.get('author', 'N/A'),
"fhir_version": pkg.get('fhir_version', 'N/A'),
"version_count": pkg.get('version_count', 0),
"canonical": pkg.get('canonical', 'N/A')
})
break
return render_template('package.dependents.html', dependents=dependents)

View File

@ -5,5 +5,9 @@ requests==2.31.0
Flask-WTF==1.2.1 Flask-WTF==1.2.1
WTForms==3.1.2 WTForms==3.1.2
Pytest Pytest
pyyaml==6.0.1
fhir.resources==8.0.0 fhir.resources==8.0.0
Flask-Migrate==4.1.0 Flask-Migrate==4.1.0
cachetools
beautifulsoup4
feedparser==6.0.11

View File

@ -6,11 +6,13 @@ import re
import logging import logging
import shutil import shutil
import sqlite3 import sqlite3
import feedparser
from flask import current_app, Blueprint, request, jsonify from flask import current_app, Blueprint, request, jsonify
from fhirpathpy import evaluate from fhirpathpy import evaluate
from collections import defaultdict, deque from collections import defaultdict, deque
from pathlib import Path from pathlib import Path
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
from types import SimpleNamespace
import datetime import datetime
import subprocess import subprocess
import tempfile import tempfile
@ -29,18 +31,44 @@ logger = logging.getLogger(__name__)
# --- ADD fhir.resources imports --- # --- ADD fhir.resources imports ---
try: try:
from fhir.resources import construct from fhir.resources import get_fhir_model_class
from fhir.resources.core.exceptions import FHIRValidationError from fhir.resources.fhirtypesvalidators import FHIRValidationError # Updated import path
FHIR_RESOURCES_AVAILABLE = True FHIR_RESOURCES_AVAILABLE = True
logger.info("fhir.resources library found. XML parsing will use this.") logger.info("fhir.resources library found. XML parsing will use this.")
except ImportError: except ImportError as e:
FHIR_RESOURCES_AVAILABLE = False FHIR_RESOURCES_AVAILABLE = False
logger.warning("fhir.resources library not found. XML parsing will be basic and dependency analysis for XML may be incomplete.") logger.warning(f"fhir.resources library failed to import. XML parsing will be basic and dependency analysis for XML may be incomplete. Error: {str(e)}")
# Define dummy classes if library not found to avoid NameErrors later # Define dummy classes if library not found to avoid NameErrors later
class FHIRValidationError(Exception): pass class FHIRValidationError(Exception): pass
def construct(resource_type, data): raise NotImplementedError("fhir.resources not installed") def get_fhir_model_class(resource_type): raise NotImplementedError("fhir.resources not installed")
except Exception as e:
FHIR_RESOURCES_AVAILABLE = False
logger.error(f"Unexpected error importing fhir.resources library: {str(e)}")
class FHIRValidationError(Exception): pass
def get_fhir_model_class(resource_type): raise NotImplementedError("fhir.resources not installed")
# --- END fhir.resources imports --- # --- END fhir.resources imports ---
# --- Check for optional 'packaging' library ---
try:
import packaging.version as pkg_version
HAS_PACKAGING_LIB = True
logger.info("Optional 'packaging' library found. Using for robust version comparison.")
except ImportError:
HAS_PACKAGING_LIB = False
logger.warning("Optional 'packaging' library not found. Using basic string comparison for versions.")
# Define a simple fallback class if packaging is missing
class BasicVersion:
def __init__(self, v_str): self.v_str = str(v_str)
# Define comparison methods for sorting compatibility
def __lt__(self, other): return self.v_str < str(other)
def __gt__(self, other): return self.v_str > str(other)
def __eq__(self, other): return self.v_str == str(other)
def __le__(self, other): return self.v_str <= str(other)
def __ge__(self, other): return self.v_str >= str(other)
def __ne__(self, other): return self.v_str != str(other)
def __str__(self): return self.v_str
pkg_version = SimpleNamespace(parse=BasicVersion, InvalidVersion=ValueError) # Mock parse and InvalidVersion
# --- Constants --- # --- Constants ---
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org" FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
DOWNLOAD_DIR_NAME = "fhir_packages" DOWNLOAD_DIR_NAME = "fhir_packages"
@ -88,6 +116,469 @@ FHIR_R4_BASE_TYPES = {
"TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription" "TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription"
} }
# -------------------------------------------------------------------
#Helper function to support normalize:
def safe_parse_version(v_str):
"""
Attempts to parse a version string using packaging.version.
Handles common FHIR suffixes like -dev, -ballot, -draft, -preview
by treating them as standard pre-releases (-a0, -b0, -rc0) for comparison.
Returns a comparable Version object or a fallback for unparseable strings.
"""
if not v_str or not isinstance(v_str, str):
# Handle None or non-string input, treat as lowest possible version
return pkg_version.parse("0.0.0a0") # Use alpha pre-release
# Try standard parsing first
try:
return pkg_version.parse(v_str)
except pkg_version.InvalidVersion:
# Handle common FHIR suffixes if standard parsing fails
original_v_str = v_str # Keep original for logging
v_str_norm = v_str.lower()
# Split into base version and suffix
base_part = v_str_norm
suffix = None
if '-' in v_str_norm:
parts = v_str_norm.split('-', 1)
base_part = parts[0]
suffix = parts[1]
# Check if base looks like a version number
if re.match(r'^\d+(\.\d+)*$', base_part):
try:
# Map FHIR suffixes to PEP 440 pre-release types for sorting
if suffix in ['dev', 'snapshot', 'ci-build']:
# Treat as alpha (earliest pre-release)
return pkg_version.parse(f"{base_part}a0")
elif suffix in ['draft', 'ballot', 'preview']:
# Treat as beta (after alpha)
return pkg_version.parse(f"{base_part}b0")
# Add more mappings if needed (e.g., -rc -> rc0)
elif suffix and suffix.startswith('rc'):
rc_num = ''.join(filter(str.isdigit, suffix)) or '0'
return pkg_version.parse(f"{base_part}rc{rc_num}")
# If suffix isn't recognized, still try parsing base as final/post
# This might happen for odd suffixes like -final (though unlikely)
# If base itself parses, use that (treats unknown suffix as > pre-release)
return pkg_version.parse(base_part)
except pkg_version.InvalidVersion:
# If base_part itself is invalid after splitting
logger.warning(f"Invalid base version '{base_part}' after splitting '{original_v_str}'. Treating as alpha.")
return pkg_version.parse("0.0.0a0")
except Exception as e:
logger.error(f"Unexpected error parsing FHIR-suffixed version '{original_v_str}': {e}")
return pkg_version.parse("0.0.0a0")
else:
# Base part doesn't look like numbers/dots (e.g., "current", "dev")
logger.warning(f"Unparseable version '{original_v_str}' (base '{base_part}' not standard). Treating as alpha.")
return pkg_version.parse("0.0.0a0") # Treat fully non-standard versions as very early
except Exception as e:
# Catch any other unexpected parsing errors
logger.error(f"Unexpected error in safe_parse_version for '{v_str}': {e}")
return pkg_version.parse("0.0.0a0") # Fallback
# --- MODIFIED FUNCTION with Enhanced Logging ---
def get_additional_registries():
"""Fetches the list of additional FHIR IG registries from the master feed."""
logger.debug("Entering get_additional_registries function")
feed_registry_url = 'https://raw.githubusercontent.com/FHIR/ig-registry/master/package-feeds.json'
feeds = [] # Default to empty list
try:
logger.info(f"Attempting to fetch feed registry from {feed_registry_url}")
# Use a reasonable timeout
response = requests.get(feed_registry_url, timeout=15)
logger.debug(f"Feed registry request to {feed_registry_url} returned status code: {response.status_code}")
# Raise HTTPError for bad responses (4xx or 5xx)
response.raise_for_status()
# Log successful fetch
logger.debug(f"Successfully fetched feed registry. Response text (first 500 chars): {response.text[:500]}...")
try:
# Attempt to parse JSON
data = json.loads(response.text)
feeds_raw = data.get('feeds', [])
# Ensure structure is as expected before adding
feeds = [{'name': feed['name'], 'url': feed['url']}
for feed in feeds_raw
if isinstance(feed, dict) and 'name' in feed and 'url' in feed]
logger.info(f"Successfully parsed {len(feeds)} valid feeds from {feed_registry_url}")
except json.JSONDecodeError as e:
# Log JSON parsing errors specifically
logger.error(f"JSON decoding error for feed registry from {feed_registry_url}: {e}")
# Log the problematic text snippet to help diagnose
logger.error(f"Problematic JSON text snippet: {response.text[:500]}...")
# feeds remains []
# --- Specific Exception Handling ---
except requests.exceptions.HTTPError as e:
logger.error(f"HTTP error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
# feeds remains []
except requests.exceptions.ConnectionError as e:
logger.error(f"Connection error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
# feeds remains []
except requests.exceptions.Timeout as e:
logger.error(f"Timeout fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
# feeds remains []
except requests.exceptions.RequestException as e:
# Catch other potential request-related errors
logger.error(f"General request error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
# feeds remains []
except Exception as e:
# Catch any other unexpected errors during the process
logger.error(f"Unexpected error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
# feeds remains []
logger.debug(f"Exiting get_additional_registries function, returning {len(feeds)} feeds.")
return feeds
# --- END MODIFIED FUNCTION ---
def fetch_packages_from_registries(search_term=''):
logger.debug("Entering fetch_packages_from_registries function with search_term: %s", search_term)
packages_dict = defaultdict(list)
try:
logger.debug("Calling get_additional_registries")
feed_registries = get_additional_registries()
logger.debug("Returned from get_additional_registries with %d registries: %s", len(feed_registries), feed_registries)
if not feed_registries:
logger.warning("No feed registries available. Cannot fetch packages.")
return []
logger.info(f"Processing {len(feed_registries)} feed registries")
for feed in feed_registries:
try:
logger.info(f"Fetching feed: {feed['name']} from {feed['url']}")
response = requests.get(feed['url'], timeout=30)
response.raise_for_status()
# Log the raw response content for debugging
response_text = response.text[:500] # Limit to first 500 chars for logging
logger.debug(f"Raw response from {feed['url']}: {response_text}")
try:
data = json.loads(response.text)
num_feed_packages = len(data.get('packages', []))
logger.info(f"Fetched from feed {feed['name']}: {num_feed_packages} packages (JSON)")
for pkg in data.get('packages', []):
if not isinstance(pkg, dict):
continue
pkg_name = pkg.get('name', '')
if not pkg_name:
continue
packages_dict[pkg_name].append(pkg)
except json.JSONDecodeError:
feed_data = feedparser.parse(response.text)
if not feed_data.entries:
logger.warning(f"No entries found in feed {feed['name']}")
continue
num_rss_packages = len(feed_data.entries)
logger.info(f"Fetched from feed {feed['name']}: {num_rss_packages} packages (Atom/RSS)")
logger.info(f"Sample feed entries from {feed['name']}: {feed_data.entries[:2]}")
for entry in feed_data.entries:
try:
# Extract package name and version from title (e.g., "hl7.fhir.au.ereq#0.3.0-preview")
title = entry.get('title', '')
if '#' in title:
pkg_name, version = title.split('#', 1)
else:
pkg_name = title
version = entry.get('version', '')
if not pkg_name:
pkg_name = entry.get('id', '') or entry.get('summary', '')
if not pkg_name:
continue
package = {
'name': pkg_name,
'version': version,
'author': entry.get('author', ''),
'fhirVersion': entry.get('fhir_version', [''])[0] or '',
'url': entry.get('link', ''),
'canonical': entry.get('canonical', ''),
'dependencies': entry.get('dependencies', []),
'pubDate': entry.get('published', entry.get('pubdate', '')),
'registry': feed['url']
}
if search_term and package['name'] and search_term.lower() not in package['name'].lower():
continue
packages_dict[pkg_name].append(package)
except Exception as entry_error:
logger.error(f"Error processing entry in feed {feed['name']}: {entry_error}")
logger.info(f"Problematic entry: {entry}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
logger.warning(f"Feed endpoint not found for {feed['name']}: {feed['url']} - 404 Not Found")
else:
logger.error(f"HTTP error fetching from feed {feed['name']}: {e}")
except requests.exceptions.RequestException as e:
logger.error(f"Request error fetching from feed {feed['name']}: {e}")
except Exception as error:
logger.error(f"Unexpected error fetching from feed {feed['name']}: {error}")
except Exception as e:
logger.error(f"Unexpected error in fetch_packages_from_registries: {e}")
# Convert packages_dict to a list of packages with aggregated versions
packages = []
for pkg_name, entries in packages_dict.items():
# Aggregate versions with their publication dates
versions = [
{
"version": entry.get('version', ''),
"pubDate": entry.get('pubDate', '')
}
for entry in entries
if entry.get('version', '')
]
# Sort versions by pubDate (newest first)
versions.sort(key=lambda x: x.get('pubDate', ''), reverse=True)
if not versions:
continue
# Take the latest entry for the main package fields
latest_entry = entries[0]
package = {
'name': pkg_name,
'version': latest_entry.get('version', ''),
'latestVersion': latest_entry.get('version', ''),
'author': latest_entry.get('author', ''),
'fhirVersion': latest_entry.get('fhirVersion', ''),
'url': latest_entry.get('url', ''),
'canonical': latest_entry.get('canonical', ''),
'dependencies': latest_entry.get('dependencies', []),
'versions': versions, # List of versions with pubDate
'registry': latest_entry.get('registry', '')
}
packages.append(package)
logger.info(f"Total packages fetched: {len(packages)}")
return packages
def normalize_package_data(raw_packages):
"""
Normalizes package data, identifying latest absolute and latest official versions.
Uses safe_parse_version for robust comparison.
"""
packages_grouped = defaultdict(list)
skipped_raw_count = 0
for entry in raw_packages:
if not isinstance(entry, dict):
skipped_raw_count += 1
logger.warning(f"Skipping raw package entry, not a dict: {entry}")
continue
raw_name = entry.get('name') or entry.get('title') or ''
if not isinstance(raw_name, str):
raw_name = str(raw_name)
name_part = raw_name.split('#', 1)[0].strip().lower()
if name_part:
packages_grouped[name_part].append(entry)
else:
if not entry.get('id'):
skipped_raw_count += 1
logger.warning(f"Skipping raw package entry, no name or id: {entry}")
logger.info(f"Initial grouping: {len(packages_grouped)} unique package names found. Skipped {skipped_raw_count} raw entries.")
normalized_list = []
skipped_norm_count = 0
total_entries_considered = 0
for name_key, entries in packages_grouped.items():
total_entries_considered += len(entries)
latest_absolute_data = None
latest_official_data = None
latest_absolute_ver_for_comp = safe_parse_version("0.0.0a0")
latest_official_ver_for_comp = safe_parse_version("0.0.0a0")
all_versions = []
package_name_display = name_key
# Aggregate all versions from entries
processed_versions = set()
for package_entry in entries:
versions_list = package_entry.get('versions', [])
for version_info in versions_list:
if isinstance(version_info, dict) and 'version' in version_info:
version_str = version_info.get('version', '')
if version_str and version_str not in processed_versions:
all_versions.append(version_info)
processed_versions.add(version_str)
processed_entries = []
for package_entry in entries:
version_str = None
raw_name_entry = package_entry.get('name') or package_entry.get('title') or ''
if not isinstance(raw_name_entry, str):
raw_name_entry = str(raw_name_entry)
version_keys = ['version', 'latestVersion']
for key in version_keys:
val = package_entry.get(key)
if isinstance(val, str) and val:
version_str = val.strip()
break
elif isinstance(val, list) and val and isinstance(val[0], str) and val[0]:
version_str = val[0].strip()
break
if not version_str and '#' in raw_name_entry:
parts = raw_name_entry.split('#', 1)
if len(parts) == 2 and parts[1]:
version_str = parts[1].strip()
if not version_str:
logger.warning(f"Skipping entry for {raw_name_entry}: no valid version found. Entry: {package_entry}")
skipped_norm_count += 1
continue
version_str = version_str.strip()
current_display_name = str(raw_name_entry).split('#')[0].strip()
if current_display_name and current_display_name != name_key:
package_name_display = current_display_name
entry_with_version = package_entry.copy()
entry_with_version['version'] = version_str
processed_entries.append(entry_with_version)
try:
current_ver_obj_for_comp = safe_parse_version(version_str)
if latest_absolute_data is None or current_ver_obj_for_comp > latest_absolute_ver_for_comp:
latest_absolute_ver_for_comp = current_ver_obj_for_comp
latest_absolute_data = entry_with_version
if re.match(r'^\d+\.\d+\.\d+(?:-[a-zA-Z0-9\.]+)?$', version_str):
if latest_official_data is None or current_ver_obj_for_comp > latest_official_ver_for_comp:
latest_official_ver_for_comp = current_ver_obj_for_comp
latest_official_data = entry_with_version
except Exception as comp_err:
logger.error(f"Error comparing version '{version_str}' for package '{package_name_display}': {comp_err}", exc_info=True)
if latest_absolute_data:
final_absolute_version = latest_absolute_data.get('version', 'unknown')
final_official_version = latest_official_data.get('version') if latest_official_data else None
author_raw = latest_absolute_data.get('author') or latest_absolute_data.get('publisher') or ''
if isinstance(author_raw, dict):
author = author_raw.get('name', str(author_raw))
elif not isinstance(author_raw, str):
author = str(author_raw)
else:
author = author_raw
fhir_version_str = None
fhir_keys = ['fhirVersion', 'fhirVersions', 'fhir_version']
for key in fhir_keys:
val = latest_absolute_data.get(key)
if isinstance(val, list) and val and isinstance(val[0], str):
fhir_version_str = val[0]
break
elif isinstance(val, str) and val:
fhir_version_str = val
break
fhir_version_str = fhir_version_str or 'unknown'
url_raw = latest_absolute_data.get('url') or latest_absolute_data.get('link') or ''
url = str(url_raw) if not isinstance(url_raw, str) else url_raw
canonical_raw = latest_absolute_data.get('canonical') or url
canonical = str(canonical_raw) if not isinstance(canonical_raw, str) else canonical_raw
dependencies_raw = latest_absolute_data.get('dependencies', [])
dependencies = []
if isinstance(dependencies_raw, dict):
dependencies = [{"name": str(dn), "version": str(dv)} for dn, dv in dependencies_raw.items()]
elif isinstance(dependencies_raw, list):
for dep in dependencies_raw:
if isinstance(dep, str):
if '@' in dep:
dep_name, dep_version = dep.split('@', 1)
dependencies.append({"name": dep_name, "version": dep_version})
else:
dependencies.append({"name": dep, "version": "N/A"})
elif isinstance(dep, dict) and 'name' in dep and 'version' in dep:
dependencies.append(dep)
# Sort all_versions by pubDate (newest first)
all_versions.sort(key=lambda x: x.get('pubDate', ''), reverse=True)
normalized_entry = {
'name': package_name_display,
'version': final_absolute_version,
'latest_absolute_version': final_absolute_version,
'latest_official_version': final_official_version,
'author': author.strip(),
'fhir_version': fhir_version_str.strip(),
'url': url.strip(),
'canonical': canonical.strip(),
'dependencies': dependencies,
'version_count': len(all_versions),
'all_versions': all_versions, # Preserve the full versions list with pubDate
'versions_data': processed_entries,
'registry': latest_absolute_data.get('registry', '')
}
normalized_list.append(normalized_entry)
if not final_official_version:
logger.warning(f"No official version found for package '{package_name_display}'. Versions: {[v['version'] for v in all_versions]}")
else:
logger.warning(f"No valid entries found to determine details for package name key '{name_key}'. Entries: {entries}")
skipped_norm_count += len(entries)
logger.info(f"Normalization complete. Entries considered: {total_entries_considered}, Skipped during norm: {skipped_norm_count}, Unique Packages Found: {len(normalized_list)}")
normalized_list.sort(key=lambda x: x.get('name', '').lower())
return normalized_list
def cache_packages(normalized_packages, db, CachedPackage):
"""
Cache normalized FHIR Implementation Guide packages in the CachedPackage database.
Updates existing records or adds new ones to improve performance for other routes.
Args:
normalized_packages (list): List of normalized package dictionaries.
db: The SQLAlchemy database instance.
CachedPackage: The CachedPackage model class.
"""
try:
for package in normalized_packages:
existing = CachedPackage.query.filter_by(package_name=package['name'], version=package['version']).first()
if existing:
existing.author = package['author']
existing.fhir_version = package['fhir_version']
existing.version_count = package['version_count']
existing.url = package['url']
existing.all_versions = package['all_versions']
existing.dependencies = package['dependencies']
existing.latest_absolute_version = package['latest_absolute_version']
existing.latest_official_version = package['latest_official_version']
existing.canonical = package['canonical']
existing.registry = package.get('registry', '')
else:
new_package = CachedPackage(
package_name=package['name'],
version=package['version'],
author=package['author'],
fhir_version=package['fhir_version'],
version_count=package['version_count'],
url=package['url'],
all_versions=package['all_versions'],
dependencies=package['dependencies'],
latest_absolute_version=package['latest_absolute_version'],
latest_official_version=package['latest_official_version'],
canonical=package['canonical'],
registry=package.get('registry', '')
)
db.session.add(new_package)
db.session.commit()
logger.info(f"Cached {len(normalized_packages)} packages in CachedPackage.")
except Exception as error:
db.session.rollback()
logger.error(f"Error caching packages: {error}")
raise
#-----------------------------------------------------------------------
# --- Helper Functions --- # --- Helper Functions ---
def _get_download_dir(): def _get_download_dir():
@ -121,6 +612,28 @@ def _get_download_dir():
logger.error(f"Unexpected error getting/creating packages directory {packages_dir}: {e}", exc_info=True) logger.error(f"Unexpected error getting/creating packages directory {packages_dir}: {e}", exc_info=True)
return None return None
# --- Helper to get description (Add this to services.py) ---
def get_package_description(package_name, package_version, packages_dir):
"""Reads package.json from a tgz and returns the description."""
tgz_filename = construct_tgz_filename(package_name, package_version)
if not tgz_filename: return "Error: Could not construct filename."
tgz_path = os.path.join(packages_dir, tgz_filename)
if not os.path.exists(tgz_path):
return f"Error: Package file not found ({tgz_filename})."
try:
with tarfile.open(tgz_path, "r:gz") as tar:
pkg_json_member = next((m for m in tar if m.name == 'package/package.json'), None)
if pkg_json_member:
with tar.extractfile(pkg_json_member) as f:
pkg_data = json.load(f)
return pkg_data.get('description', 'No description found in package.json.')
else:
return "Error: package.json not found in archive."
except (tarfile.TarError, json.JSONDecodeError, KeyError, IOError, Exception) as e:
logger.error(f"Error reading description from {tgz_filename}: {e}")
return f"Error reading package details: {e}"
def sanitize_filename_part(text): def sanitize_filename_part(text):
"""Basic sanitization for name/version parts of filename.""" """Basic sanitization for name/version parts of filename."""
if not isinstance(text, str): if not isinstance(text, str):
@ -2785,7 +3298,8 @@ def process_and_upload_test_data(server_info, options, temp_file_dir):
raise ValueError("XML root tag missing.") raise ValueError("XML root tag missing.")
temp_dict = basic_fhir_xml_to_dict(content) temp_dict = basic_fhir_xml_to_dict(content)
if temp_dict: if temp_dict:
fhir_resource = construct(resource_type, temp_dict) model_class = get_fhir_model_class(resource_type)
fhir_resource = model_class(**temp_dict)
resource_dict = fhir_resource.dict(exclude_none=True) resource_dict = fhir_resource.dict(exclude_none=True)
if 'id' in resource_dict: if 'id' in resource_dict:
parsed_content_list.append(resource_dict) parsed_content_list.append(resource_dict)
@ -3228,6 +3742,367 @@ def process_and_upload_test_data(server_info, options, temp_file_dir):
# --- END Service Function --- # --- END Service Function ---
# --- CORRECTED retrieve_bundles function with NEW logic ---
def retrieve_bundles(fhir_server_url, resources, output_zip, validate_references=False, fetch_reference_bundles=False):
"""
Retrieve FHIR bundles and save to a ZIP file.
Optionally fetches referenced resources, either individually by ID or as full bundles by type.
Yields NDJSON progress updates.
"""
temp_dir = None
try:
total_initial_bundles = 0
fetched_individual_references = 0
fetched_type_bundles = 0
retrieved_references_or_types = set() # Track fetched items to avoid duplicates
temp_dir = tempfile.mkdtemp(prefix="fhir_retrieve_")
logger.debug(f"Created temporary directory for bundle retrieval: {temp_dir}")
yield json.dumps({"type": "progress", "message": f"Starting bundle retrieval for {len(resources)} resource types"}) + "\n"
if validate_references:
yield json.dumps({"type": "info", "message": f"Reference fetching ON (Mode: {'Full Type Bundles' if fetch_reference_bundles else 'Individual Resources'})"}) + "\n"
else:
yield json.dumps({"type": "info", "message": "Reference fetching OFF"}) + "\n"
# --- Determine Base URL and Headers for Proxy ---
base_proxy_url = 'http://localhost:5000/fhir' # Always target the proxy endpoint
headers = {'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8'}
is_custom_url = fhir_server_url != '/fhir' and fhir_server_url is not None and fhir_server_url.startswith('http')
if is_custom_url:
headers['X-Target-FHIR-Server'] = fhir_server_url.rstrip('/')
logger.debug(f"Will use proxy with X-Target-FHIR-Server: {headers['X-Target-FHIR-Server']}")
else:
logger.debug("Will use proxy targeting local HAPI server")
# --- Fetch Initial Bundles ---
initial_bundle_files = [] # Store paths for reference scanning
for resource_type in resources:
url = f"{base_proxy_url}/{quote(resource_type)}"
yield json.dumps({"type": "progress", "message": f"Fetching bundle for {resource_type} via proxy..."}) + "\n"
logger.debug(f"Sending GET request to proxy {url} with headers: {json.dumps(headers)}")
try:
response = requests.get(url, headers=headers, timeout=60)
logger.debug(f"Proxy response for {resource_type}: HTTP {response.status_code}")
if response.status_code != 200:
# ... (keep existing error handling for initial fetch) ...
error_detail = f"Proxy returned HTTP {response.status_code}."
try: error_detail += f" Body: {response.text[:200]}..."
except: pass
yield json.dumps({"type": "error", "message": f"Failed to fetch {resource_type}: {error_detail}"}) + "\n"
logger.error(f"Failed to fetch {resource_type} via proxy {url}: {error_detail}")
continue
try: bundle = response.json()
except ValueError as e:
yield json.dumps({"type": "error", "message": f"Invalid JSON response for {resource_type}: {str(e)}"}) + "\n"
logger.error(f"Invalid JSON from proxy for {resource_type} at {url}: {e}, Response: {response.text[:500]}")
continue
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
yield json.dumps({"type": "error", "message": f"Expected Bundle for {resource_type}, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
logger.error(f"Expected Bundle for {resource_type}, got {bundle.get('resourceType', 'unknown')}")
continue
if not bundle.get('entry'):
yield json.dumps({"type": "warning", "message": f"No entries found in bundle for {resource_type}"}) + "\n"
# Save the bundle
output_file = os.path.join(temp_dir, f"{resource_type}_bundle.json")
try:
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
logger.debug(f"Wrote bundle to {output_file}")
initial_bundle_files.append(output_file) # Add for scanning
total_initial_bundles += 1
yield json.dumps({"type": "success", "message": f"Saved bundle for {resource_type}"}) + "\n"
except IOError as e:
yield json.dumps({"type": "error", "message": f"Failed to save bundle file for {resource_type}: {e}"}) + "\n"
logger.error(f"Failed to write bundle file {output_file}: {e}")
continue
except requests.RequestException as e:
yield json.dumps({"type": "error", "message": f"Error connecting to proxy for {resource_type}: {str(e)}"}) + "\n"
logger.error(f"Error retrieving bundle for {resource_type} via proxy {url}: {e}")
continue
except Exception as e:
yield json.dumps({"type": "error", "message": f"Unexpected error fetching {resource_type}: {str(e)}"}) + "\n"
logger.error(f"Unexpected error during initial fetch for {resource_type} at {url}: {e}", exc_info=True)
continue
# --- Fetch Referenced Resources (Conditionally) ---
if validate_references and initial_bundle_files:
yield json.dumps({"type": "progress", "message": "Scanning retrieved bundles for references..."}) + "\n"
all_references = set()
references_by_type = defaultdict(set) # To store { 'Patient': {'id1', 'id2'}, ... }
# --- Scan for References ---
for bundle_file_path in initial_bundle_files:
try:
with open(bundle_file_path, 'r', encoding='utf-8') as f: bundle = json.load(f)
for entry in bundle.get('entry', []):
resource = entry.get('resource')
if resource:
current_refs = []
find_references(resource, current_refs)
for ref_str in current_refs:
if isinstance(ref_str, str) and '/' in ref_str and not ref_str.startswith('#'):
all_references.add(ref_str)
# Group by type for bundle fetch mode
try:
ref_type = ref_str.split('/')[0]
if ref_type: references_by_type[ref_type].add(ref_str)
except Exception: pass # Ignore parsing errors here
except Exception as e:
yield json.dumps({"type": "warning", "message": f"Could not scan references in {os.path.basename(bundle_file_path)}: {e}"}) + "\n"
logger.warning(f"Error processing references in {bundle_file_path}: {e}")
# --- Fetch Logic ---
if not all_references:
yield json.dumps({"type": "info", "message": "No references found to fetch."}) + "\n"
else:
if fetch_reference_bundles:
# --- Fetch Full Bundles by Type ---
unique_ref_types = sorted(list(references_by_type.keys()))
yield json.dumps({"type": "progress", "message": f"Fetching full bundles for {len(unique_ref_types)} referenced types..."}) + "\n"
logger.info(f"Fetching full bundles for referenced types: {unique_ref_types}")
for ref_type in unique_ref_types:
if ref_type in retrieved_references_or_types: continue # Skip if type bundle already fetched
url = f"{base_proxy_url}/{quote(ref_type)}" # Fetch all of this type
yield json.dumps({"type": "progress", "message": f"Fetching full bundle for type {ref_type} via proxy..."}) + "\n"
logger.debug(f"Sending GET request for full type bundle {ref_type} to proxy {url} with headers: {json.dumps(headers)}")
try:
response = requests.get(url, headers=headers, timeout=180) # Longer timeout for full bundles
logger.debug(f"Proxy response for {ref_type} bundle: HTTP {response.status_code}")
if response.status_code != 200:
error_detail = f"Proxy returned HTTP {response.status_code}."
try: error_detail += f" Body: {response.text[:200]}..."
except: pass
yield json.dumps({"type": "warning", "message": f"Failed to fetch full bundle for {ref_type}: {error_detail}"}) + "\n"
logger.warning(f"Failed to fetch full bundle {ref_type} via proxy {url}: {error_detail}")
retrieved_references_or_types.add(ref_type) # Mark type as attempted
continue
try: bundle = response.json()
except ValueError as e:
yield json.dumps({"type": "warning", "message": f"Invalid JSON for full {ref_type} bundle: {str(e)}"}) + "\n"
logger.warning(f"Invalid JSON response from proxy for full {ref_type} bundle at {url}: {e}")
retrieved_references_or_types.add(ref_type)
continue
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
yield json.dumps({"type": "warning", "message": f"Expected Bundle for full {ref_type} fetch, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
logger.warning(f"Expected Bundle for full {ref_type} fetch, got {bundle.get('resourceType', 'unknown')}")
retrieved_references_or_types.add(ref_type)
continue
# Save the full type bundle
output_file = os.path.join(temp_dir, f"ref_{ref_type}_BUNDLE.json")
try:
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
logger.debug(f"Wrote full type bundle to {output_file}")
fetched_type_bundles += 1
retrieved_references_or_types.add(ref_type)
yield json.dumps({"type": "success", "message": f"Saved full bundle for type {ref_type}"}) + "\n"
except IOError as e:
yield json.dumps({"type": "warning", "message": f"Failed to save full bundle file for {ref_type}: {e}"}) + "\n"
logger.error(f"Failed to write full bundle file {output_file}: {e}")
retrieved_references_or_types.add(ref_type) # Still mark as attempted
except requests.RequestException as e:
yield json.dumps({"type": "warning", "message": f"Error connecting to proxy for full {ref_type} bundle: {str(e)}"}) + "\n"
logger.warning(f"Error retrieving full {ref_type} bundle via proxy: {e}")
retrieved_references_or_types.add(ref_type)
except Exception as e:
yield json.dumps({"type": "warning", "message": f"Unexpected error fetching full {ref_type} bundle: {str(e)}"}) + "\n"
logger.warning(f"Unexpected error during full {ref_type} bundle fetch: {e}", exc_info=True)
retrieved_references_or_types.add(ref_type)
# End loop through ref_types
else:
# --- Fetch Individual Referenced Resources ---
yield json.dumps({"type": "progress", "message": f"Fetching {len(all_references)} unique referenced resources individually..."}) + "\n"
logger.info(f"Fetching {len(all_references)} unique referenced resources by ID.")
for ref in sorted(list(all_references)): # Sort for consistent order
if ref in retrieved_references_or_types: continue # Skip already fetched
try:
# Parse reference
ref_parts = ref.split('/')
if len(ref_parts) != 2 or not ref_parts[0] or not ref_parts[1]:
logger.warning(f"Skipping invalid reference format: {ref}")
continue
ref_type, ref_id = ref_parts
# Fetch individual resource using _id search
search_param = quote(f"_id={ref_id}")
url = f"{base_proxy_url}/{quote(ref_type)}?{search_param}"
yield json.dumps({"type": "progress", "message": f"Fetching referenced {ref_type}/{ref_id} via proxy..."}) + "\n"
logger.debug(f"Sending GET request for referenced {ref} to proxy {url} with headers: {json.dumps(headers)}")
response = requests.get(url, headers=headers, timeout=60)
logger.debug(f"Proxy response for referenced {ref}: HTTP {response.status_code}")
if response.status_code != 200:
error_detail = f"Proxy returned HTTP {response.status_code}."
try: error_detail += f" Body: {response.text[:200]}..."
except: pass
yield json.dumps({"type": "warning", "message": f"Failed to fetch referenced {ref}: {error_detail}"}) + "\n"
logger.warning(f"Failed to fetch referenced {ref} via proxy {url}: {error_detail}")
retrieved_references_or_types.add(ref)
continue
try: bundle = response.json()
except ValueError as e:
yield json.dumps({"type": "warning", "message": f"Invalid JSON for referenced {ref}: {str(e)}"}) + "\n"
logger.warning(f"Invalid JSON from proxy for ref {ref} at {url}: {e}")
retrieved_references_or_types.add(ref)
continue
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
yield json.dumps({"type": "warning", "message": f"Expected Bundle for referenced {ref}, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
retrieved_references_or_types.add(ref)
continue
if not bundle.get('entry'):
yield json.dumps({"type": "info", "message": f"Referenced resource {ref} not found on server." }) + "\n"
logger.info(f"Referenced resource {ref} not found via search {url}")
retrieved_references_or_types.add(ref)
continue
# Save the bundle containing the single referenced resource
# Use a filename indicating it's an individual reference fetch
output_file = os.path.join(temp_dir, f"ref_{ref_type}_{ref_id}.json")
try:
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
logger.debug(f"Wrote referenced resource bundle to {output_file}")
fetched_individual_references += 1
retrieved_references_or_types.add(ref)
yield json.dumps({"type": "success", "message": f"Saved referenced resource {ref}"}) + "\n"
except IOError as e:
yield json.dumps({"type": "warning", "message": f"Failed to save file for referenced {ref}: {e}"}) + "\n"
logger.error(f"Failed to write file {output_file}: {e}")
retrieved_references_or_types.add(ref)
except requests.RequestException as e:
yield json.dumps({"type": "warning", "message": f"Network error fetching referenced {ref}: {str(e)}"}) + "\n"
logger.warning(f"Network error retrieving referenced {ref} via proxy: {e}")
retrieved_references_or_types.add(ref)
except Exception as e:
yield json.dumps({"type": "warning", "message": f"Unexpected error fetching referenced {ref}: {str(e)}"}) + "\n"
logger.warning(f"Unexpected error during reference fetch for {ref}: {e}", exc_info=True)
retrieved_references_or_types.add(ref)
# End loop through individual references
# --- End Reference Fetching Logic ---
# --- Create Final ZIP File ---
yield json.dumps({"type": "progress", "message": f"Creating ZIP file {os.path.basename(output_zip)}..."}) + "\n"
files_to_zip = [f for f in os.listdir(temp_dir) if f.endswith('.json')]
if not files_to_zip:
yield json.dumps({"type": "warning", "message": "No bundle files were successfully retrieved to include in ZIP."}) + "\n"
logger.warning(f"No JSON files found in {temp_dir} to include in ZIP.")
else:
logger.debug(f"Found {len(files_to_zip)} JSON files to include in ZIP: {files_to_zip}")
try:
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
for filename in files_to_zip:
file_path = os.path.join(temp_dir, filename)
if os.path.exists(file_path): zipf.write(file_path, filename)
else: logger.error(f"File {file_path} disappeared before adding to ZIP.")
yield json.dumps({"type": "success", "message": f"ZIP file created: {os.path.basename(output_zip)} with {len(files_to_zip)} files."}) + "\n"
except Exception as e:
yield json.dumps({"type": "error", "message": f"Failed to create ZIP file: {e}"}) + "\n"
logger.error(f"Error creating ZIP file {output_zip}: {e}", exc_info=True)
# --- Final Completion Message ---
completion_message = (
f"Bundle retrieval finished. Initial bundles: {total_initial_bundles}, "
f"Referenced items fetched: {fetched_individual_references if not fetch_reference_bundles else fetched_type_bundles} "
f"({ 'individual resources' if not fetch_reference_bundles else 'full type bundles' })."
)
yield json.dumps({
"type": "complete",
"message": completion_message,
"data": {
"total_initial_bundles": total_initial_bundles,
"fetched_individual_references": fetched_individual_references,
"fetched_type_bundles": fetched_type_bundles,
"reference_mode": "individual" if validate_references and not fetch_reference_bundles else "type_bundle" if validate_references and fetch_reference_bundles else "off"
}
}) + "\n"
except Exception as e:
# Catch errors during setup (like temp dir creation)
yield json.dumps({"type": "error", "message": f"Critical error during retrieval setup: {str(e)}"}) + "\n"
logger.error(f"Unexpected error in retrieve_bundles setup: {e}", exc_info=True)
yield json.dumps({ "type": "complete", "message": f"Retrieval failed: {str(e)}", "data": {"total_initial_bundles": 0, "fetched_individual_references": 0, "fetched_type_bundles": 0} }) + "\n"
finally:
# --- Cleanup Temporary Directory ---
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
logger.debug(f"Successfully removed temporary directory: {temp_dir}")
except Exception as cleanup_e:
logger.error(f"Error removing temporary directory {temp_dir}: {cleanup_e}", exc_info=True)
# --- End corrected retrieve_bundles function ---
def split_bundles(input_zip_path, output_zip):
"""Split FHIR bundles from a ZIP file into individual resource JSON files and save to a ZIP."""
try:
total_resources = 0
temp_dir = tempfile.mkdtemp()
yield json.dumps({"type": "progress", "message": f"Starting bundle splitting from ZIP"}) + "\n"
# Extract input ZIP
with zipfile.ZipFile(input_zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
yield json.dumps({"type": "progress", "message": f"Extracted input ZIP to temporary directory"}) + "\n"
# Process JSON files
for filename in os.listdir(temp_dir):
if not filename.endswith('.json'):
continue
input_file = os.path.join(temp_dir, filename)
try:
with open(input_file, 'r', encoding='utf-8') as f:
bundle = json.load(f)
if bundle.get('resourceType') != 'Bundle':
yield json.dumps({"type": "error", "message": f"Skipping {filename}: Not a Bundle"}) + "\n"
continue
yield json.dumps({"type": "progress", "message": f"Processing bundle {filename}"}) + "\n"
index = 1
for entry in bundle.get('entry', []):
resource = entry.get('resource')
if not resource or not resource.get('resourceType'):
yield json.dumps({"type": "error", "message": f"Invalid resource in {filename} at entry {index}"}) + "\n"
continue
resource_type = resource['resourceType']
output_file = os.path.join(temp_dir, f"{resource_type}-{index}.json")
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(resource, f, indent=2)
total_resources += 1
yield json.dumps({"type": "success", "message": f"Saved {resource_type}-{index}.json"}) + "\n"
index += 1
except Exception as e:
yield json.dumps({"type": "error", "message": f"Error processing {filename}: {str(e)}"}) + "\n"
logger.error(f"Error splitting bundle {filename}: {e}", exc_info=True)
# Create output ZIP
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
for filename in os.listdir(temp_dir):
if filename.endswith('.json') and '-' in filename:
zipf.write(os.path.join(temp_dir, filename), filename)
yield json.dumps({
"type": "complete",
"message": f"Bundle splitting completed. Extracted {total_resources} resources.",
"data": {"total_resources": total_resources}
}) + "\n"
except Exception as e:
yield json.dumps({"type": "error", "message": f"Unexpected error during splitting: {str(e)}"}) + "\n"
logger.error(f"Unexpected error in split_bundles: {e}", exc_info=True)
finally:
if os.path.exists(temp_dir):
for filename in os.listdir(temp_dir):
os.remove(os.path.join(temp_dir, filename))
os.rmdir(temp_dir)
# --- Standalone Test --- # --- Standalone Test ---
if __name__ == '__main__': if __name__ == '__main__':

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,12 @@
body { body {
width: 100%; width: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Apply overflow and height constraints only for fire animation page */
body.fire-animation-page {
overflow: hidden; overflow: hidden;
height: 100vh; height: 100vh;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} }
/* Fire animation overlay */ /* Fire animation overlay */

View File

@ -1,225 +1,223 @@
/* Scoped to .fire-loading-overlay to prevent conflicts */ /* /app/static/css/fire-animation.css */
.fire-loading-overlay {
position: fixed; /* Removed html, body, stage styles */
top: 0;
left: 0; .minifire-container { /* Add a wrapper for positioning/sizing */
width: 100%;
height: 100%;
background: rgba(39, 5, 55, 0.8); /* Semi-transparent gradient background */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1050; /* Above Bootstrap modals */
}
.fire-loading-overlay .campfire {
position: relative; position: relative;
width: 400px; /* Reduced size for better fit */ height: 130px; /* Overall height of the animation area */
height: 400px; overflow: hidden; /* Hide parts extending beyond the container */
transform-origin: center center; background-color: #270537; /* Optional: Add a background */
transform: scale(0.6); /* Scaled down for responsiveness */ border-radius: 4px;
border: 1px solid #444;
margin-top: 0.5rem; /* Space above animation */
} }
.fire-loading-overlay .log { .minifire-campfire {
position: relative;
/* Base size significantly reduced (original was 600px) */
width: 150px;
height: 150px;
transform-origin: bottom center;
/* Scale down slightly more if needed, adjusted positioning based on origin */
transform: scale(0.8) translateY(15px); /* Pushes it down slightly */
}
/* --- Scaled Down Logs --- */
.minifire-log {
position: absolute; position: absolute;
width: 238px; width: 60px; /* 238/4 */
height: 70px; height: 18px; /* 70/4 */
border-radius: 32px; border-radius: 8px; /* 32/4 */
background: #781e20; background: #781e20;
overflow: hidden; overflow: hidden;
opacity: 0.99; opacity: 0.99;
transform-origin: center center;
box-shadow: 0 0 1px 0.5px rgba(0,0,0,0.15); /* Scaled shadow */
} }
.fire-loading-overlay .log:before { .minifire-log:before {
content: ''; content: '';
display: block; display: block;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 35px; left: 9px; /* 35/4 */
width: 8px; width: 2px; /* 8/4 */
height: 8px; height: 2px; /* 8/4 */
border-radius: 32px; border-radius: 8px; /* 32/4 */
background: #b35050; background: #b35050;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: 3; z-index: 3;
box-shadow: 0 0 0 2.5px #781e20, 0 0 0 10.5px #b35050, 0 0 0 13px #781e20, 0 0 0 21px #b35050, 0 0 0 23.5px #781e20, 0 0 0 31.5px #b35050; /* Scaled box-shadows */
box-shadow: 0 0 0 0.5px #781e20, /* 2.5/4 -> 0.6 -> 0.5 */
0 0 0 2.5px #b35050, /* 10.5/4 -> 2.6 -> 2.5 */
0 0 0 3.5px #781e20, /* 13/4 -> 3.25 -> 3.5 */
0 0 0 5.5px #b35050, /* 21/4 -> 5.25 -> 5.5 */
0 0 0 6px #781e20, /* 23.5/4 -> 5.9 -> 6 */
0 0 0 8px #b35050; /* 31.5/4 -> 7.9 -> 8 */
} }
.fire-loading-overlay .streak { .minifire-streak {
position: absolute; position: absolute;
height: 2px; height: 1px; /* Min height */
border-radius: 20px; border-radius: 5px; /* 20/4 */
background: #b35050; background: #b35050;
} }
/* Scaled streaks */
.minifire-streak:nth-child(1) { top: 3px; width: 23px; } /* 10/4, 90/4 */
.minifire-streak:nth-child(2) { top: 3px; left: 25px; width: 20px; } /* 10/4, 100/4, 80/4 */
.minifire-streak:nth-child(3) { top: 3px; left: 48px; width: 8px; } /* 10/4, 190/4, 30/4 */
.minifire-streak:nth-child(4) { top: 6px; width: 33px; } /* 22/4, 132/4 */
.minifire-streak:nth-child(5) { top: 6px; left: 36px; width: 12px; } /* 22/4, 142/4, 48/4 */
.minifire-streak:nth-child(6) { top: 6px; left: 50px; width: 7px; } /* 22/4, 200/4, 28/4 */
.minifire-streak:nth-child(7) { top: 9px; left: 19px; width: 40px; } /* 34/4, 74/4, 160/4 */
.minifire-streak:nth-child(8) { top: 12px; left: 28px; width: 10px; } /* 46/4, 110/4, 40/4 */
.minifire-streak:nth-child(9) { top: 12px; left: 43px; width: 14px; } /* 46/4, 170/4, 54/4 */
.minifire-streak:nth-child(10) { top: 15px; left: 23px; width: 28px; } /* 58/4, 90/4, 110/4 */
/* Streak positioning (unchanged from original) */ /* Scaled Log Positions (Relative to 150px campfire) */
.fire-loading-overlay .streak:nth-child(1) { top: 10px; width: 90px; } .minifire-log:nth-child(1) { bottom: 25px; left: 25px; transform: rotate(150deg) scaleX(0.75); z-index: 20; } /* 100/4, 100/4 */
.fire-loading-overlay .streak:nth-child(2) { top: 10px; left: 100px; width: 80px; } .minifire-log:nth-child(2) { bottom: 30px; left: 35px; transform: rotate(110deg) scaleX(0.75); z-index: 10; } /* 120/4, 140/4 */
.fire-loading-overlay .streak:nth-child(3) { top: 10px; left: 190px; width: 30px; } .minifire-log:nth-child(3) { bottom: 25px; left: 17px; transform: rotate(-10deg) scaleX(0.75); } /* 98/4, 68/4 */
.fire-loading-overlay .streak:nth-child(4) { top: 22px; width: 132px; } .minifire-log:nth-child(4) { bottom: 20px; left: 55px; transform: rotate(-120deg) scaleX(0.75); z-index: 26; } /* 80/4, 220/4 */
.fire-loading-overlay .streak:nth-child(5) { top: 22px; left: 142px; width: 48px; } .minifire-log:nth-child(5) { bottom: 19px; left: 53px; transform: rotate(-30deg) scaleX(0.75); z-index: 25; } /* 75/4, 210/4 */
.fire-loading-overlay .streak:nth-child(6) { top: 22px; left: 200px; width: 28px; } .minifire-log:nth-child(6) { bottom: 23px; left: 70px; transform: rotate(35deg) scaleX(0.85); z-index: 30; } /* 92/4, 280/4 */
.fire-loading-overlay .streak:nth-child(7) { top: 34px; left: 74px; width: 160px; } .minifire-log:nth-child(7) { bottom: 18px; left: 75px; transform: rotate(-30deg) scaleX(0.75); z-index: 20; } /* 70/4, 300/4 */
.fire-loading-overlay .streak:nth-child(8) { top: 46px; left: 110px; width: 40px; }
.fire-loading-overlay .streak:nth-child(9) { top: 46px; left: 170px; width: 54px; }
.fire-loading-overlay .streak:nth-child(10) { top: 58px; left: 90px; width: 110px; }
.fire-loading-overlay .log { /* --- Scaled Down Sticks --- */
transform-origin: center center; .minifire-stick {
box-shadow: 0 0 2px 1px rgba(0,0,0,0.15);
}
.fire-loading-overlay .log:nth-child(1) { bottom: 100px; left: 100px; transform: rotate(150deg) scaleX(0.75); z-index: 20; }
.fire-loading-overlay .log:nth-child(2) { bottom: 120px; left: 140px; transform: rotate(110deg) scaleX(0.75); z-index: 10; }
.fire-loading-overlay .log:nth-child(3) { bottom: 98px; left: 68px; transform: rotate(-10deg) scaleX(0.75); }
.fire-loading-overlay .log:nth-child(4) { bottom: 80px; left: 220px; transform: rotate(-120deg) scaleX(0.75); z-index: 26; }
.fire-loading-overlay .log:nth-child(5) { bottom: 75px; left: 210px; transform: rotate(-30deg) scaleX(0.75); z-index: 25; }
.fire-loading-overlay .log:nth-child(6) { bottom: 92px; left: 280px; transform: rotate(35deg) scaleX(0.85); z-index: 30; }
.fire-loading-overlay .log:nth-child(7) { bottom: 70px; left: 300px; transform: rotate(-30deg) scaleX(0.75); z-index: 20; }
.fire-loading-overlay .stick {
position: absolute; position: absolute;
width: 68px; width: 17px; /* 68/4 */
height: 20px; height: 5px; /* 20/4 */
border-radius: 10px; border-radius: 3px; /* 10/4 */
box-shadow: 0 0 2px 1px rgba(0,0,0,0.1); box-shadow: 0 0 1px 0.5px rgba(0,0,0,0.1);
background: #781e20; background: #781e20;
transform-origin: center center;
} }
.minifire-stick:before {
.fire-loading-overlay .stick:before {
content: ''; content: '';
display: block; display: block;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 30px; left: 7px; /* 30/4 -> 7.5 */
width: 6px; width: 1.5px; /* 6/4 */
height: 20px; height: 5px; /* 20/4 */
background: #781e20; background: #781e20;
border-radius: 10px; border-radius: 3px; /* 10/4 */
transform: translateY(50%) rotate(32deg); transform: translateY(50%) rotate(32deg);
} }
.minifire-stick:after {
.fire-loading-overlay .stick:after {
content: ''; content: '';
display: block; display: block;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
width: 20px; width: 5px; /* 20/4 */
height: 20px; height: 5px; /* 20/4 */
background: #b35050; background: #b35050;
border-radius: 10px; border-radius: 3px; /* 10/4 */
} }
/* Scaled Stick Positions */
.minifire-stick:nth-child(1) { left: 40px; bottom: 41px; transform: rotate(-152deg) scaleX(0.8); z-index: 12; } /* 158/4, 164/4 */
.minifire-stick:nth-child(2) { left: 45px; bottom: 8px; transform: rotate(20deg) scaleX(0.9); } /* 180/4, 30/4 */
.minifire-stick:nth-child(3) { left: 100px; bottom: 10px; transform: rotate(170deg) scaleX(0.9); } /* 400/4, 38/4 */
.minifire-stick:nth-child(3):before { display: none; }
.minifire-stick:nth-child(4) { left: 93px; bottom: 38px; transform: rotate(80deg) scaleX(0.9); z-index: 20; } /* 370/4, 150/4 */
.minifire-stick:nth-child(4):before { display: none; }
.fire-loading-overlay .stick { /* --- Scaled Down Fire --- */
transform-origin: center center; .minifire-fire .minifire-flame {
}
.fire-loading-overlay .stick:nth-child(1) { left: 158px; bottom: 164px; transform: rotate(-152deg) scaleX(0.8); z-index: 12; }
.fire-loading-overlay .stick:nth-child(2) { left: 180px; bottom: 30px; transform: rotate(20deg) scaleX(0.9); }
.fire-loading-overlay .stick:nth-child(3) { left: 400px; bottom: 38px; transform: rotate(170deg) scaleX(0.9); }
.fire-loading-overlay .stick:nth-child(3):before { display: none; }
.fire-loading-overlay .stick:nth-child(4) { left: 370px; bottom: 150px; transform: rotate(80deg) scaleX(0.9); z-index: 20; }
.fire-loading-overlay .stick:nth-child(4):before { display: none; }
.fire-loading-overlay .fire .flame {
position: absolute; position: absolute;
transform-origin: bottom center; transform-origin: bottom center;
opacity: 0.9; opacity: 0.9;
} }
.fire-loading-overlay .fire__red .flame { /* Red Flames */
width: 48px; .minifire-fire__red .minifire-flame {
border-radius: 48px; width: 12px; /* 48/4 */
border-radius: 12px; /* 48/4 */
background: #e20f00; background: #e20f00;
box-shadow: 0 0 80px 18px rgba(226,15,0,0.4); box-shadow: 0 0 20px 5px rgba(226,15,0,0.4); /* Scaled shadow */
} }
/* Scaled positions/heights */
.minifire-fire__red .minifire-flame:nth-child(1) { left: 35px; height: 40px; bottom: 25px; animation: minifire-fire 2s 0.15s ease-in-out infinite alternate; } /* 138/4, 160/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(2) { left: 47px; height: 60px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; } /* 186/4, 240/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(3) { left: 59px; height: 75px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; } /* 234/4, 300/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(4) { left: 71px; height: 90px; bottom: 25px; animation: minifire-fire 2s 0s ease-in-out infinite alternate; } /* 282/4, 360/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(5) { left: 83px; height: 78px; bottom: 25px; animation: minifire-fire 2s 0.45s ease-in-out infinite alternate; } /* 330/4, 310/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(6) { left: 95px; height: 58px; bottom: 25px; animation: minifire-fire 2s 0.3s ease-in-out infinite alternate; } /* 378/4, 232/4, 100/4 */
.minifire-fire__red .minifire-flame:nth-child(7) { left: 107px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; } /* 426/4, 140/4, 100/4 */
.fire-loading-overlay .fire__red .flame:nth-child(1) { left: 138px; height: 160px; bottom: 100px; animation: fire 2s 0.15s ease-in-out infinite alternate; } /* Orange Flames */
.fire-loading-overlay .fire__red .flame:nth-child(2) { left: 186px; height: 240px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; } .minifire-fire__orange .minifire-flame {
.fire-loading-overlay .fire__red .flame:nth-child(3) { left: 234px; height: 300px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; } width: 12px; border-radius: 12px; background: #ff9c00;
.fire-loading-overlay .fire__red .flame:nth-child(4) { left: 282px; height: 360px; bottom: 100px; animation: fire 2s 0s ease-in-out infinite alternate; } box-shadow: 0 0 20px 5px rgba(255,156,0,0.4);
.fire-loading-overlay .fire__red .flame:nth-child(5) { left: 330px; height: 310px; bottom: 100px; animation: fire 2s 0.45s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__red .flame:nth-child(6) { left: 378px; height: 232px; bottom: 100px; animation: fire 2s 0.3s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__red .flame:nth-child(7) { left: 426px; height: 140px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__orange .flame {
width: 48px;
border-radius: 48px;
background: #ff9c00;
box-shadow: 0 0 80px 18px rgba(255,156,0,0.4);
} }
.minifire-fire__orange .minifire-flame:nth-child(1) { left: 35px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.05s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(2) { left: 47px; height: 53px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(3) { left: 59px; height: 63px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(4) { left: 71px; height: 75px; bottom: 25px; animation: minifire-fire 2s 0.4s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(5) { left: 83px; height: 65px; bottom: 25px; animation: minifire-fire 2s 0.5s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(6) { left: 95px; height: 51px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; }
.minifire-fire__orange .minifire-flame:nth-child(7) { left: 107px; height: 28px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__orange .flame:nth-child(1) { left: 138px; height: 140px; bottom: 100px; animation: fire 2s 0.05s ease-in-out infinite alternate; } /* Yellow Flames */
.fire-loading-overlay .fire__orange .flame:nth-child(2) { left: 186px; height: 210px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; } .minifire-fire__yellow .minifire-flame {
.fire-loading-overlay .fire__orange .flame:nth-child(3) { left: 234px; height: 250px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; } width: 12px; border-radius: 12px; background: #ffeb6e;
.fire-loading-overlay .fire__orange .flame:nth-child(4) { left: 282px; height: 300px; bottom: 100px; animation: fire 2s 0.4s ease-in-out infinite alternate; } box-shadow: 0 0 20px 5px rgba(255,235,110,0.4);
.fire-loading-overlay .fire__orange .flame:nth-child(5) { left: 330px; height: 260px; bottom: 100px; animation: fire 2s 0.5s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__orange .flame:nth-child(6) { left: 378px; height: 202px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__orange .flame:nth-child(7) { left: 426px; height: 110px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__yellow .flame {
width: 48px;
border-radius: 48px;
background: #ffeb6e;
box-shadow: 0 0 80px 18px rgba(255,235,110,0.4);
} }
.minifire-fire__yellow .minifire-flame:nth-child(1) { left: 47px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.6s ease-in-out infinite alternate; }
.minifire-fire__yellow .minifire-flame:nth-child(2) { left: 59px; height: 43px; bottom: 30px; animation: minifire-fire 2s 0.4s ease-in-out infinite alternate; } /* Adjusted bottom slightly */
.minifire-fire__yellow .minifire-flame:nth-child(3) { left: 71px; height: 60px; bottom: 25px; animation: minifire-fire 2s 0.38s ease-in-out infinite alternate; }
.minifire-fire__yellow .minifire-flame:nth-child(4) { left: 83px; height: 50px; bottom: 25px; animation: minifire-fire 2s 0.22s ease-in-out infinite alternate; }
.minifire-fire__yellow .minifire-flame:nth-child(5) { left: 95px; height: 36px; bottom: 25px; animation: minifire-fire 2s 0.18s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__yellow .flame:nth-child(1) { left: 186px; height: 140px; bottom: 100px; animation: fire 2s 0.6s ease-in-out infinite alternate; } /* White Flames */
.fire-loading-overlay .fire__yellow .flame:nth-child(2) { left: 234px; height: 172px; bottom: 120px; animation: fire 2s 0.4s ease-in-out infinite alternate; } .minifire-fire__white .minifire-flame {
.fire-loading-overlay .fire__yellow .flame:nth-child(3) { left: 282px; height: 240px; bottom: 100px; animation: fire 2s 0.38s ease-in-out infinite alternate; } width: 12px; border-radius: 12px; background: #fef1d9;
.fire-loading-overlay .fire__yellow .flame:nth-child(4) { left: 330px; height: 200px; bottom: 100px; animation: fire 2s 0.22s ease-in-out infinite alternate; } box-shadow: 0 0 20px 5px rgba(254,241,217,0.4);
.fire-loading-overlay .fire__yellow .flame:nth-child(5) { left: 378px; height: 142px; bottom: 100px; animation: fire 2s 0.18s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame {
width: 48px;
border-radius: 48px;
background: #fef1d9;
box-shadow: 0 0 80px 18px rgba(254,241,217,0.4);
} }
.minifire-fire__white .minifire-flame:nth-child(1) { left: 39px; width: 8px; height: 25px; bottom: 25px; animation: minifire-fire 2s 0.22s ease-in-out infinite alternate; } /* Scaled width too */
.minifire-fire__white .minifire-flame:nth-child(2) { left: 45px; width: 8px; height: 30px; bottom: 25px; animation: minifire-fire 2s 0.42s ease-in-out infinite alternate; }
.minifire-fire__white .minifire-flame:nth-child(3) { left: 59px; height: 43px; bottom: 25px; animation: minifire-fire 2s 0.32s ease-in-out infinite alternate; }
.minifire-fire__white .minifire-flame:nth-child(4) { left: 71px; height: 53px; bottom: 25px; animation: minifire-fire 2s 0.8s ease-in-out infinite alternate; }
.minifire-fire__white .minifire-flame:nth-child(5) { left: 83px; height: 43px; bottom: 25px; animation: minifire-fire 2s 0.85s ease-in-out infinite alternate; }
.minifire-fire__white .minifire-flame:nth-child(6) { left: 95px; width: 8px; height: 28px; bottom: 25px; animation: minifire-fire 2s 0.64s ease-in-out infinite alternate; }
.minifire-fire__white .minifire-flame:nth-child(7) { left: 102px; width: 8px; height: 25px; bottom: 25px; animation: minifire-fire 2s 0.32s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame:nth-child(1) { left: 156px; width: 32px; height: 100px; bottom: 100px; animation: fire 2s 0.22s ease-in-out infinite alternate; } /* --- Scaled Down Sparks --- */
.fire-loading-overlay .fire__white .flame:nth-child(2) { left: 181px; width: 32px; height: 120px; bottom: 100px; animation: fire 2s 0.42s ease-in-out infinite alternate; } .minifire-spark {
.fire-loading-overlay .fire__white .flame:nth-child(3) { left: 234px; height: 170px; bottom: 100px; animation: fire 2s 0.32s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame:nth-child(4) { left: 282px; height: 210px; bottom: 100px; animation: fire 2s 0.8s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame:nth-child(5) { left: 330px; height: 170px; bottom: 100px; animation: fire 2s 0.85s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame:nth-child(6) { left: 378px; width: 32px; height: 110px; bottom: 100px; animation: fire 2s 0.64s ease-in-out infinite alternate; }
.fire-loading-overlay .fire__white .flame:nth-child(7) { left: 408px; width: 32px; height: 100px; bottom: 100px; animation: fire 2s 0.32s ease-in-out infinite alternate; }
.fire-loading-overlay .spark {
position: absolute; position: absolute;
width: 6px; width: 1.5px; /* 6/4 */
height: 20px; height: 5px; /* 20/4 */
background: #fef1d9; background: #fef1d9;
border-radius: 18px; border-radius: 5px; /* 18/4 -> 4.5 */
z-index: 50; z-index: 50;
transform-origin: bottom center; transform-origin: bottom center;
transform: scaleY(0); transform: scaleY(0);
} }
/* Scaled spark positions/animations */
.minifire-spark:nth-child(1) { left: 40px; bottom: 53px; animation: minifire-spark 1s 0.4s linear infinite; } /* 160/4, 212/4 */
.minifire-spark:nth-child(2) { left: 45px; bottom: 60px; animation: minifire-spark 1s 1s linear infinite; } /* 180/4, 240/4 */
.minifire-spark:nth-child(3) { left: 52px; bottom: 80px; animation: minifire-spark 1s 0.8s linear infinite; } /* 208/4, 320/4 */
.minifire-spark:nth-child(4) { left: 78px; bottom: 100px; animation: minifire-spark 1s 2s linear infinite; } /* 310/4, 400/4 */
.minifire-spark:nth-child(5) { left: 90px; bottom: 95px; animation: minifire-spark 1s 0.75s linear infinite; } /* 360/4, 380/4 */
.minifire-spark:nth-child(6) { left: 98px; bottom: 80px; animation: minifire-spark 1s 0.65s linear infinite; } /* 390/4, 320/4 */
.minifire-spark:nth-child(7) { left: 100px; bottom: 70px; animation: minifire-spark 1s 1s linear infinite; } /* 400/4, 280/4 */
.minifire-spark:nth-child(8) { left: 108px; bottom: 53px; animation: minifire-spark 1s 1.4s linear infinite; } /* 430/4, 210/4 */
.fire-loading-overlay .spark:nth-child(1) { left: 160px; bottom: 212px; animation: spark 1s 0.4s linear infinite; } /* --- Keyframes (Rename to avoid conflicts) --- */
.fire-loading-overlay .spark:nth-child(2) { left: 180px; bottom: 240px; animation: spark 1s 1s linear infinite; } /* Use the same keyframe logic, just rename them */
.fire-loading-overlay .spark:nth-child(3) { left: 208px; bottom: 320px; animation: spark 1s 0.8s linear infinite; } @keyframes minifire-fire {
.fire-loading-overlay .spark:nth-child(4) { left: 310px; bottom: 400px; animation: spark 1s 2s linear infinite; } 0% { transform: scaleY(1); } 28% { transform: scaleY(0.7); } 38% { transform: scaleY(0.8); } 50% { transform: scaleY(0.6); } 70% { transform: scaleY(0.95); } 82% { transform: scaleY(0.58); } 100% { transform: scaleY(1); }
.fire-loading-overlay .spark:nth-child(5) { left: 360px; bottom: 380px; animation: spark 1s 0.75s linear infinite; }
.fire-loading-overlay .spark:nth-child(6) { left: 390px; bottom: 320px; animation: spark 1s 0.65s linear infinite; }
.fire-loading-overlay .spark:nth-child(7) { left: 400px; bottom: 280px; animation: spark 1s 1s linear infinite; }
.fire-loading-overlay .spark:nth-child(8) { left: 430px; bottom: 210px; animation: spark 1s 1.4s linear infinite; }
@keyframes fire {
0% { transform: scaleY(1); }
28% { transform: scaleY(0.7); }
38% { transform: scaleY(0.8); }
50% { transform: scaleY(0.6); }
70% { transform: scaleY(0.95); }
82% { transform: scaleY(0.58); }
100% { transform: scaleY(1); }
} }
@keyframes minifire-spark {
@keyframes spark {
0%, 35% { transform: scaleY(0) translateY(0); opacity: 0; } 0%, 35% { transform: scaleY(0) translateY(0); opacity: 0; }
50% { transform: scaleY(1) translateY(0); opacity: 1; } 50% { transform: scaleY(1) translateY(0); opacity: 1; }
70% { transform: scaleY(1) translateY(-10px); opacity: 1; } /* Adjusted translateY for smaller scale */
75% { transform: scaleY(1) translateY(-10px); opacity: 0; } 70% { transform: scaleY(1) translateY(-3px); opacity: 1; } /* 10/4 -> 2.5 -> 3 */
75% { transform: scaleY(1) translateY(-3px); opacity: 0; }
100% { transform: scaleY(0) translateY(0); opacity: 0; } 100% { transform: scaleY(0) translateY(0); opacity: 0; }
} }

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
{# templates/_flash_messages.html #}
{# Check if there are any flashed messages #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{# Loop through messages and display them as Bootstrap alerts #}
{% for category, message in messages %}
{# Map Flask message categories (e.g., 'error', 'success', 'warning') to Bootstrap alert classes #}
{% set alert_class = 'alert-info' %} {# Default class #}
{% if category == 'error' or category == 'danger' %}
{% set alert_class = 'alert-danger' %}
{% elif category == 'success' %}
{% set alert_class = 'alert-success' %}
{% elif category == 'warning' %}
{% set alert_class = 'alert-warning' %}
{% endif %}
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,113 @@
{# templates/_search_results_table.html #}
{# This partial template renders the search results table and pagination #}
{% if packages %}
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Package</th>
<th scope="col">Latest</th>
<th scope="col">Author</th>
<th scope="col">FHIR</th>
<th scope="col">Versions</th>
<!-- <th scope="col">Canonical</th> -->
{# Add Dependencies header if needed based on your data #}
{# <th scope="col">Dependencies</th> #}
</tr>
</thead>
<tbody>
{% for pkg in packages %}
<tr>
<td>
<i class="bi bi-box-seam me-2"></i>
{# Link to package details page - adjust if endpoint name is different #}
<a class="text-primary"
href="{{ url_for('package_details_view', name=pkg.name) }}"
{# Optional: Add HTMX GET for inline details if desired #}
{# hx-get="{{ url_for('package_details', name=pkg.name) }}" #}
{# hx-target="#search-results" #}
>
{{ pkg.name }}
</a>
</td>
<td>{{ pkg.display_version }}</td>
<td>{{ pkg.author or '' }}</td>
<td>{{ pkg.fhir_version or '' }}</td>
<td>{{ pkg.version_count }}</td>
<!-- <td>{{ pkg.canonical or '' }}</td> -->
{# Add Dependencies data if needed #}
{# <td>{{ pkg.dependencies | join(', ') if pkg.dependencies else '' }}</td> #}
</tr>
{% endfor %}
</tbody>
</table>
{# Pagination Controls - Ensure 'pagination' object is passed from the route #}
{% if pagination and pagination.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{# Previous Page Link #}
{% if pagination.has_prev %}
<li class="page-item">
{# Use hx-get for HTMX-powered pagination within the results area #}
<a class="page-link"
href="{{ url_for('search_and_import', page=pagination.prev_num, search=request.args.get('search', '')) }}" {# Keep standard link for non-JS fallback #}
hx-get="{{ url_for('api_search_packages', page=pagination.prev_num, search=request.args.get('search', '')) }}" {# HTMX target #}
hx-target="#search-results"
hx-indicator=".htmx-indicator"
aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">&laquo;</span>
</li>
{% endif %}
{# Page Number Links #}
{% for p in pagination.iter_pages %} {# Iterate over the pre-computed list #}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('search_and_import', page=p, search=request.args.get('search', '')) }}"
hx-get="{{ url_for('api_search_packages', page=p, search=request.args.get('search', '')) }}"
hx-target="#search-results"
hx-indicator=".htmx-indicator">
{{ p }}
</a>
</li>
{% else %}
{# Ellipsis for skipped pages #}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
{# Next Page Link #}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('search_and_import', page=pagination.next_num, search=request.args.get('search', '')) }}"
hx-get="{{ url_for('api_search_packages', page=pagination.next_num, search=request.args.get('search', '')) }}"
hx-target="#search-results"
hx-indicator=".htmx-indicator"
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">&raquo;</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %} {# End pagination nav #}
{% elif request and request.args.get('search') %}
{# Message when search term is present but no results found #}
<p class="text-muted text-center mt-3">No packages found matching your search term.</p>
{% else %}
{# Initial message before any search #}
<p class="text-muted text-center mt-3">Start typing in the search box above to find packages.</p>
{% endif %}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="{% if request.cookies.get('theme') == 'dark' %}dark{% else %}light{% endif %}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -10,8 +10,13 @@
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/animation.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/animation.css') }}">
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<title>{% if app_mode == 'lite' %}(Lite Version) {% endif %}{% if title %}{{ title }} - {% endif %}{{ site_name }}</title> <title>{% if app_mode == 'lite' %}(Lite Version) {% endif %}{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
<style> <style>
/* Prevent flash of unstyled content */
:root { visibility: hidden; }
[data-theme="dark"], [data-theme="light"] { visibility: visible; }
/* Default (Light Theme) Styles */ /* Default (Light Theme) Styles */
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -88,7 +93,7 @@
height: 1.25rem !important; height: 1.25rem !important;
margin-top: 0.25rem; margin-top: 0.25rem;
display: inline-block !important; display: inline-block !important;
border: 1px solide #6c757d; border: 1px solid #6c757d;
} }
.form-check-input:checked { .form-check-input:checked {
background-color: #007bff; background-color: #007bff;
@ -289,20 +294,18 @@
display: block; /* Make code fill pre */ display: block; /* Make code fill pre */
} }
/* Dark Theme Override */ /* Dark Theme Override */
html[data-theme="dark"] #fsh-output pre, html[data-theme="dark"] #fsh-output pre,
html[data-theme="dark"] ._fsh_output pre html[data-theme="dark"] ._fsh_output pre {
{
background-color: #495057; /* Dark theme background */ background-color: #495057; /* Dark theme background */
color: #f8f9fa; /* Dark theme text */ color: #f8f9fa; /* Dark theme text */
border-color: #6c757d; border-color: #6c757d;
} }
html[data-theme="dark"] #fsh-output pre code, html[data-theme="dark"] #fsh-output pre code,
html[data-theme="dark"] ._fsh_output pre code { html[data-theme="dark"] ._fsh_output pre code {
color: inherit; color: inherit;
} }
/* --- Theme Styles for FHIR UI Operations (fhir_ui_operations.html) --- */ /* --- Theme Styles for FHIR UI Operations (fhir_ui_operations.html) --- */
@ -477,7 +480,6 @@
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: green"] { color: #20c997 !important; } html[data-theme="dark"] .execute-wrapper .response-status[style*="color: green"] { color: #20c997 !important; }
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: red"] { color: #e63946 !important; } html[data-theme="dark"] .execute-wrapper .response-status[style*="color: red"] { color: #e63946 !important; }
/* Specific Action Buttons (Success, Danger, Warning, Info) */ /* Specific Action Buttons (Success, Danger, Warning, Info) */
.btn-success { background-color: #198754; border-color: #198754; color: white; } .btn-success { background-color: #198754; border-color: #198754; color: white; }
.btn-success:hover { background-color: #157347; border-color: #146c43; } .btn-success:hover { background-color: #157347; border-color: #146c43; }
@ -752,7 +754,7 @@
} }
</style> </style>
</head> </head>
<body class="d-flex flex-column min-vh-100"> <body class="d-flex flex-column min-vh-100 {% block body_class %}{% endblock %}">
<nav class="navbar navbar-expand-lg navbar-light"> <nav class="navbar navbar-expand-lg navbar-light">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}{% if app_mode == 'lite' %} (Lite){% endif %}</a> <a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}{% if app_mode == 'lite' %} (Lite){% endif %}</a>
@ -765,10 +767,13 @@
<a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="fas fa-house me-1"></i> Home</a> <a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="fas fa-house me-1"></i> Home</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a> <a class="nav-link {% if request.path == '/search-and-import' %}active{% endif %}" href="{{ url_for('search_and_import') }}"><i class="fas fa-download me-1"></i> Search and Import</a>
</li> </li>
<li class="nav-item"> <!-- <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'import_ig' else '' }}" href="{{ url_for('import_ig') }}"><i class="fas fa-download me-1"></i> Import IGs</a> <a class="nav-link {{ 'active' if request.endpoint == 'import_ig' else '' }}" href="{{ url_for('import_ig') }}"><i class="fas fa-download me-1"></i> Import IGs</a>
</li> -->
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="fas fa-upload me-1"></i> Push IGs</a> <a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="fas fa-upload me-1"></i> Push IGs</a>
@ -779,6 +784,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="fas fa-check-circle me-1"></i> Validate FHIR Sample</a> <a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="fas fa-check-circle me-1"></i> Validate FHIR Sample</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'retrieve_split_data' else '' }}" href="{{ url_for('retrieve_split_data') }}"><i class="fas bi-cloud-download me-1"></i> Retrieve & Split Data</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui' else '' }}" href="{{ url_for('fhir_ui') }}"><i class="fas fa-cloud-download-alt me-1"></i> FHIR API Explorer</a> <a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui' else '' }}" href="{{ url_for('fhir_ui') }}"><i class="fas fa-cloud-download-alt me-1"></i> FHIR API Explorer</a>
</li> </li>
@ -788,6 +796,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a> <a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
</li> </li>
{% if app_mode != 'lite' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>
</li>
{% endif %}
</ul> </ul>
<div class="navbar-controls d-flex align-items-center"> <div class="navbar-controls d-flex align-items-center">
<div class="form-check form-switch"> <div class="form-check form-switch">
@ -849,41 +862,38 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script> <script>
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
function toggleTheme() { function toggleTheme() {
const html = document.documentElement; const html = document.documentElement;
const toggle = document.getElementById('themeToggle'); const toggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun'); const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon'); const moonIcon = document.querySelector('.fa-moon');
if (toggle.checked) { const newTheme = toggle.checked ? 'dark' : 'light';
html.setAttribute('data-theme', 'dark'); html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', newTheme);
sunIcon.classList.add('d-none'); sunIcon.classList.toggle('d-none', toggle.checked);
moonIcon.classList.remove('d-none'); moonIcon.classList.toggle('d-none', !toggle.checked);
} else { document.cookie = `theme=${newTheme}; path=/; max-age=31536000`;
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
sunIcon.classList.remove('d-none');
moonIcon.classList.add('d-none');
}
if (typeof loadLottieAnimation === 'function') { if (typeof loadLottieAnimation === 'function') {
const newTheme = toggle.checked ? 'dark' : 'light';
loadLottieAnimation(newTheme); loadLottieAnimation(newTheme);
} }
// Set cookie to persist theme
document.cookie = `theme=${toggle.checked ? 'dark' : 'light'}; path=/; max-age=31536000`;
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme') || (document.cookie.includes('theme=dark') ? 'dark' : 'light'); const savedTheme = localStorage.getItem('theme') || getCookie('theme') || 'light';
const themeToggle = document.getElementById('themeToggle'); const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun'); const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon'); const moonIcon = document.querySelector('.fa-moon');
if (savedTheme === 'dark' && themeToggle) { document.documentElement.setAttribute('data-theme', savedTheme);
document.documentElement.setAttribute('data-theme', 'dark'); themeToggle.checked = savedTheme === 'dark';
themeToggle.checked = true; sunIcon.classList.toggle('d-none', savedTheme === 'dark');
sunIcon.classList.add('d-none'); moonIcon.classList.toggle('d-none', savedTheme !== 'dark');
moonIcon.classList.remove('d-none');
}
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(t => new bootstrap.Tooltip(t)); tooltips.forEach(t => new bootstrap.Tooltip(t));
}); });

234
templates/config_hapi.html Normal file
View File

@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-4">HAPI FHIR Configuration Manager</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div id="loading" class="text-center my-4">
<p>Loading configuration...</p>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="error" class="alert alert-danger d-none" role="alert"></div>
<div id="config-form" class="d-none">
<div class="mb-3">
<button id="save-btn" class="btn btn-primary me-2">Save Configuration</button>
<button id="restart-btn" class="btn btn-success">Restart HAPI Server</button>
<span id="restart-status" class="ms-3 text-success d-none"></span>
</div>
<div id="config-sections"></div>
</div>
</div>
<script>
// Configuration descriptions from HAPI FHIR JPA documentation
const CONFIG_DESCRIPTIONS = {
'hapi.fhir.narrative_enabled': 'Enables or disables the generation of FHIR resource narratives (human-readable summaries). Set to false to reduce processing overhead. Default: false.',
'hapi.fhir.mdm_enabled': 'Enables Master Data Management (MDM) features for linking and matching resources (e.g., Patient records). Requires mdm_rules_json_location to be set. Default: false.',
'hapi.fhir.mdm_rules_json_location': 'Path to the JSON file defining MDM rules for resource matching. Example: "mdm-rules.json".',
'hapi.fhir.cors.allow_Credentials': 'Allows credentials (e.g., cookies, authorization headers) in CORS requests. Set to true to support authenticated cross-origin requests. Default: true.',
'hapi.fhir.cors.allowed_origin': 'List of allowed origins for CORS requests. Use ["*"] to allow all origins or specify domains (e.g., ["https://example.com"]). Default: ["*"].',
'hapi.fhir.tester.home.name': 'Name of the local tester configuration for HAPI FHIR testing UI. Example: "Local Tester".',
'hapi.fhir.tester.home.server_address': 'URL of the local FHIR server for testing. Example: "http://localhost:8080/fhir".',
'hapi.fhir.tester.home.refuse_to_fetch_third_party_urls': 'Prevents the tester from fetching third-party URLs. Set to true for security. Default: false.',
'hapi.fhir.tester.home.fhir_version': 'FHIR version for the local tester (e.g., "R4", "R5"). Default: R4.',
'hapi.fhir.tester.global.name': 'Name of the global tester configuration for HAPI FHIR testing UI. Example: "Global Tester".',
'hapi.fhir.tester.global.server_address': 'URL of the global FHIR server for testing. Example: "http://hapi.fhir.org/baseR4".',
'hapi.fhir.tester.global.refuse_to_fetch_third_party_urls': 'Prevents the global tester from fetching third-party URLs. Default: false.',
'hapi.fhir.tester.global.fhir_version': 'FHIR version for the global tester (e.g., "R4"). Default: R4.',
'hapi.fhir.cr.enabled': 'Enables Clinical Reasoning (CR) module for operations like evaluate-measure. Default: false.',
'hapi.fhir.cr.caregaps.reporter': 'Reporter mode for care gaps in the CR module. Default: "default".',
'hapi.fhir.cr.caregaps.section_author': 'Author identifier for care gap sections. Default: "default".',
'hapi.fhir.cr.cql.use_embedded_libraries': 'Uses embedded CQL libraries for Clinical Quality Language processing. Default: true.',
'hapi.fhir.inline_resource_storage_below_size': 'Maximum size (in bytes) for storing FHIR resources inline in the database. Default: 4000.',
'hapi.fhir.advanced_lucene_indexing': 'Enables experimental advanced Lucene/Elasticsearch indexing for enhanced search capabilities. Default: false. Note: May not support all FHIR features like _total=accurate.',
'hapi.fhir.enable_index_of_type': 'Enables indexing of resource types for faster searches. Default: true.',
// Add more descriptions as needed based on your application.yaml
};
document.addEventListener('DOMContentLoaded', () => {
const configForm = document.getElementById('config-form');
const loading = document.getElementById('loading');
const errorDiv = document.getElementById('error');
const configSections = document.getElementById('config-sections');
const saveBtn = document.getElementById('save-btn');
const restartBtn = document.getElementById('restart-btn');
const restartStatus = document.getElementById('restart-status');
let configData = {};
// Fetch initial configuration
fetch('/api/config')
.then(res => res.json())
.then(data => {
configData = data;
renderConfigSections();
loading.classList.add('d-none');
configForm.classList.remove('d-none');
// Initialize Bootstrap tooltips
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
})
.catch(err => {
showError('Failed to load configuration');
loading.classList.add('d-none');
});
// Render configuration sections
function renderConfigSections() {
configSections.innerHTML = '';
Object.entries(configData).forEach(([section, values]) => {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'card mb-3';
sectionDiv.innerHTML = `
<div class="card-header">
<h3 class="card-title">${section.charAt(0).toUpperCase() + section.slice(1)}</h3>
</div>
<div class="card-body">
${renderInputs(values, [section])}
</div>
`;
configSections.appendChild(sectionDiv);
});
}
// Render inputs recursively with descriptions
function renderInputs(obj, path) {
let html = '';
Object.entries(obj).forEach(([key, value]) => {
const inputId = path.concat(key).join('-');
const configPath = path.concat(key).join('.');
const description = CONFIG_DESCRIPTIONS[configPath] || 'No description available.';
if (typeof value === 'boolean') {
html += `
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="${inputId}" ${value ? 'checked' : ''}>
<label class="form-check-label" for="${inputId}">
${key}
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
</label>
</div>
`;
} else if (typeof value === 'string' || typeof value === 'number') {
html += `
<div class="mb-2">
<label for="${inputId}" class="form-label">
${key}
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
</label>
<input type="text" class="form-control" id="${inputId}" value="${value}">
</div>
`;
} else if (Array.isArray(value)) {
html += `
<div class="mb-2">
<label for="${inputId}" class="form-label">
${key} (comma-separated)
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip" data-bs-placement="top" title="${description}"></i>
</label>
<input type="text" class="form-control" id="${inputId}" value="${value.join(',')}">
</div>
`;
} else if (typeof value === 'object' && value !== null) {
html += `
<div class="border-start ps-3 ms-3">
<h4>${key}</h4>
${renderInputs(value, path.concat(key))}
</div>
`;
}
});
return html;
}
// Update config data on input change
configSections.addEventListener('change', (e) => {
const path = e.target.id.split('-');
let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
if (e.target.type === 'text' && e.target.value.includes(',')) {
value = e.target.value.split(',').map(v => v.trim());
}
updateConfig(path, value);
});
// Update config object
function updateConfig(path, value) {
let current = configData;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
current[path[path.length - 1]] = value;
}
// Save configuration
saveBtn.addEventListener('click', () => {
fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configData)
})
.then(res => res.json())
.then(data => {
if (data.error) {
showError(data.error);
} else {
showSuccess('Configuration saved successfully');
}
})
.catch(() => showError('Failed to save configuration'));
});
// Restart HAPI server
restartBtn.addEventListener('click', () => {
restartStatus.textContent = 'Restarting...';
restartStatus.classList.remove('d-none');
fetch('/api/restart-tomcat', { method: 'POST' })
.then(res => res.json())
.then(data => {
if (data.error) {
showError(data.error);
restartStatus.classList.add('d-none');
} else {
restartStatus.textContent = 'HAPI server restarted successfully';
setTimeout(() => {
restartStatus.classList.add('d-none');
restartStatus.textContent = '';
}, 3000);
}
})
.catch(() => {
showError('Failed to restart HAPI server');
restartStatus.classList.add('d-none');
});
});
// Show error message
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
setTimeout(() => errorDiv.classList.add('d-none'), 5000);
}
// Show success message
function showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.querySelector('.container').insertBefore(alert, configForm);
setTimeout(() => alert.remove(), 5000);
}
});
</script>
{% endblock %}

View File

@ -12,149 +12,32 @@
</div> </div>
</div> </div>
<!-- Fire Animation Loading Overlay -->
<div id="fire-loading-overlay" class="fire-loading-overlay d-none"> <div id="fire-loading-overlay" class="fire-loading-overlay d-none">
<div class="campfire"> <div class="campfire">
<div class="sparks"> <div class="campfire-scaler">
<div class="spark"></div> <div class="sparks">
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
<div class="spark"></div>
<div class="spark"></div>
<div class="spark"></div>
<div class="spark"></div>
<div class="spark"></div>
</div>
<div class="logs">
<div class="log">
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div> </div>
<div class="log"> <div class="logs">
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div> <div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div> </div>
<div class="log"> <div class="sticks">
<div class="streak"></div> <div class="stick"></div><div class="stick"></div><div class="stick"></div><div class="stick"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div> </div>
<div class="log"> <div class="fire">
<div class="streak"></div> <div class="fire__red"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
<div class="streak"></div> <div class="fire__orange"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
<div class="streak"></div> <div class="fire__yellow"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
<div class="streak"></div> <div class="fire__white"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div> </div>
<div class="log"> </div> </div> <div class="loading-text text-center">
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div>
<div class="log">
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div>
<div class="log">
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
<div class="streak"></div>
</div>
</div>
<div class="sticks">
<div class="stick"></div>
<div class="stick"></div>
<div class="stick"></div>
<div class="stick"></div>
</div>
<div class="fire">
<div class="fire__red">
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
</div>
<div class="fire__orange">
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
</div>
<div class="fire__yellow">
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
</div>
<div class="fire__white">
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
<div class="flame"></div>
</div>
</div>
</div>
<div class="text-center mt-3">
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p> <p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
<p id="log-display" class="text-white mb-0"></p> <p id="log-display" class="text-white mb-0"></p>
</div> </div>
@ -183,82 +66,80 @@
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // Your existing JS here (exactly as provided before)
const form = document.getElementById('import-ig-form'); document.addEventListener('DOMContentLoaded', function() {
const submitBtn = document.getElementById('submit-btn'); const form = document.getElementById('import-ig-form');
const loadingOverlay = document.getElementById('fire-loading-overlay'); const submitBtn = document.getElementById('submit-btn');
const logDisplay = document.getElementById('log-display'); const loadingOverlay = document.getElementById('fire-loading-overlay');
const logDisplay = document.getElementById('log-display');
if (form && submitBtn && loadingOverlay && logDisplay) { if (form && submitBtn && loadingOverlay && logDisplay) {
form.addEventListener('submit', async function(event) { form.addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
submitBtn.disabled = true; submitBtn.disabled = true;
logDisplay.textContent = 'Starting import...'; logDisplay.textContent = 'Starting import...';
loadingOverlay.classList.remove('d-none'); loadingOverlay.classList.remove('d-none'); // Show the overlay
let eventSource; let eventSource;
try { try {
// Start SSE connection for logs eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
eventSource = new EventSource('{{ url_for('stream_import_logs') }}'); eventSource.onmessage = function(event) {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
logDisplay.textContent = event.data;
};
eventSource.onerror = function() {
console.error('SSE connection error');
if(eventSource) eventSource.close();
logDisplay.textContent = 'Log streaming interrupted.';
};
} catch (e) {
console.error('Failed to initialize SSE:', e);
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
}
eventSource.onmessage = function(event) { const formData = new FormData(form);
if (event.data === '[DONE]') { try {
const response = await fetch('{{ url_for('import_ig') }}', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
});
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
eventSource.close(); eventSource.close();
return;
} }
logDisplay.textContent = event.data;
};
eventSource.onerror = function() { if (!response.ok) {
console.error('SSE connection error'); const errorData = await response.json().catch(() => ({ message: `HTTP error: ${response.status}` }));
eventSource.close(); throw new Error(errorData.message || `HTTP error: ${response.status}`);
logDisplay.textContent = 'Log streaming interrupted. Import may still be in progress.'; }
};
} catch (e) {
console.error('Failed to initialize SSE:', e);
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
}
// Submit form via AJAX const data = await response.json();
const formData = new FormData(form); if (data.redirect) {
try { window.location.href = data.redirect;
const response = await fetch('{{ url_for('import_ig') }}', { } else {
method: 'POST', // Optional: display success message
headers: { 'X-Requested-With': 'XMLHttpRequest' }, }
body: formData } catch (error) {
}); console.error('Import error:', error);
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
if (eventSource) eventSource.close(); eventSource.close();
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error: ${response.status}`);
}
const data = await response.json();
if (data.redirect) {
window.location.href = data.redirect;
} else {
const alertDiv = document.createElement('div'); const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success mt-3'; alertDiv.className = 'alert alert-danger mt-3';
alertDiv.textContent = data.message || 'Import successful!'; alertDiv.textContent = `Import failed: ${error.message}`;
form.parentElement.appendChild(alertDiv); form.closest('.card-body').appendChild(alertDiv);
} finally {
loadingOverlay.classList.add('d-none'); // Hide overlay when done
submitBtn.disabled = false;
} }
} catch (error) { });
console.error('Import error:', error); } else {
if (eventSource) eventSource.close(); console.error('Required elements missing for import script.');
const alertDiv = document.createElement('div'); }
alertDiv.className = 'alert alert-danger mt-3'; });
alertDiv.textContent = `Import failed: ${error.message}`;
form.parentElement.appendChild(alertDiv);
} finally {
loadingOverlay.classList.add('d-none');
submitBtn.disabled = false;
}
});
} else {
console.error('Required elements missing: form, submit button, loading overlay, or log display.');
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body_class %}fire-animation-page{% endblock %}
{% block content %} {% block content %}
<div class="px-4 py-5 my-5 text-center"> <div class="px-4 py-5 my-5 text-center">
<div id="logo-container" class="mb-4"> <div id="logo-container" class="mb-4">
@ -59,7 +61,8 @@
<h5 class="card-title">IG Management</h5> <h5 class="card-title">IG Management</h5>
<p class="card-text">Import and manage FHIR Implementation Guides.</p> <p class="card-text">Import and manage FHIR Implementation Guides.</p>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a> <!-- <a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a> -->
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary">Search & Import IGs</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a> <a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a> <a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
</div> </div>
@ -75,6 +78,7 @@
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a> <a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a> <a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a>
<a href="{{ url_for('retrieve_split_data') }}" class="btn btn-outline-secondary">Retrieve & Split Data</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,7 @@
<ul class="list-group">
{% for canonical in canonicals %}
<li class="list-group-item">
<a href="{{ canonical }}" target="_blank">{{ canonical }}</a>
</li>
{% endfor %}
</ul>

View File

@ -0,0 +1,31 @@
{% if dependents %}
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Package</th>
<th scope="col">Latest</th>
<th scope="col">Author</th>
<th scope="col">FHIR</th>
<th scope="col">Versions</th>
<th scope="col">Canonical</th>
</tr>
</thead>
<tbody>
{% for dep in dependents %}
<tr>
<td>
<i class="bi bi-box-seam me-2"></i>
<a class="text-primary" href="{{ url_for('package_details_view', name=dep.name) }}">{{ dep.name }}</a>
</td>
<td>{{ dep.version }}</td>
<td>{{ dep.author }}</td>
<td>{{ dep.fhir_version }}</td>
<td>{{ dep.version_count }}</td>
<td>{{ dep.canonical }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No dependent packages found for this package.</p>
{% endif %}

View File

@ -0,0 +1,22 @@
{% if logs %}
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Version</th>
<th scope="col">Publication Date</th>
<th scope="col">When</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.version }}</td>
<td>{{ log.pubDate }}</td>
<td>{{ log.when }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No version history found for this package.</p>
{% endif %}

View File

@ -0,0 +1,16 @@
<table class="table table-striped">
<thead>
<tr>
<th>Package</th>
<th>Dependency</th>
</tr>
</thead>
<tbody>
{% for problem in problems %}
<tr>
<td>{{ problem.package }}</td>
<td class="text-danger">{{ problem.dependency }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-5">
<div class="row">
<div class="col-md-9">
{# Package Header #}
<h1 class="display-5 fw-bold mb-1">{{ package_json.name }} <span class="text-muted">v{{ package_json.version }}</span></h1>
<p class="text-muted mb-3">
Latest Version: {{ package_json.version }}
{% if latest_official_version and latest_official_version != package_json.version %}
| Latest Official: {{ latest_official_version }}
{% endif %}
</p>
{# Install Commands #}
<div class="mb-3">
<label for="npmInstall" class="form-label small">Install package</label>
<div class="input-group input-group-sm">
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
{% set registry_url = registry_base | replace('/rssfeed', '') %}
<input type="text" class="form-control form-control-sm" id="npmInstall" readonly
value="npm --registry {{ registry_url }} install {{ package_json.name }}@{{ package_json.version }}">
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('npmInstall')" aria-label="Copy NPM command">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="mb-4">
<label for="curlCommand" class="form-label small">Get resources</label>
<div class="input-group input-group-sm">
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
{% set registry_url = registry_base | replace('/rssfeed', '') %}
<input type="text" class="form-control form-control-sm" id="curlCommand" readonly
value="curl {{ registry_url }}/{{ package_json.name }}/{{ package_json.version }}/package.tgz | gunzip | tar xf -">
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('curlCommand')" aria-label="Copy curl command">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="mb-4">
<label for="packageURL" class="form-label small">Straight Package Download</label>
<div class="input-group input-group-sm">
{% set registry_base = package_json.registry | default('https://packages.simplifier.net') %}
{% set registry_url = registry_base | replace('/rssfeed', '') %}
<input type="text" class="form-control form-control-sm" id="packageURL" readonly
value="{{ package_json.canonical }}">
<button class="btn btn-outline-secondary btn-sm" type="button" onclick="copyToClipboard('packageURL')" aria-label="Copy curl command">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
{# Description #}
<h5 class="mt-4">Description</h5>
<p>{{ package_json.description | default('No description provided.', true) }}</p>
{# Dependencies #}
<h5 class="mt-4">Dependencies</h5>
{% if dependencies %}
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Package</th>
<th scope="col">Version</th>
</tr>
</thead>
<tbody>
{% for dep in dependencies %}
<tr>
<td>{{ dep.name }}</td>
<td>{{ dep.version }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No dependencies found for this package.</p>
{% endif %}
{# Dependents #}
<h5 class="mt-4">Dependents</h5>
<div id="dependents-content" hx-get="{{ url_for('package.dependents', name=package_name) }}" hx-trigger="load" hx-swap="innerHTML">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...
</div>
{# Logs #}
<h5 class="mt-4">Logs</h5>
<div id="logs-content" hx-get="{{ url_for('package.logs', name=package_name) }}" hx-trigger="load" hx-swap="innerHTML">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading...
</div>
</div>
<div class="col-md-3">
<h3 class="mb-3">Versions ({{ versions | length }})</h3>
<ul class="list-group list-group-flush">
{% for version in versions %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ version }}</span>
{% if version == latest_official_version %}
<i class="fas fa-star text-warning" title="Latest Official Release"></i>
{% endif %}
</li>
{% else %}
<li class="list-group-item">No versions found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script>
function copyToClipboard(elementId) {
var copyText = document.getElementById(elementId);
copyText.select();
copyText.setSelectionRange(0, 99999); /* For mobile devices */
navigator.clipboard.writeText(copyText.value).then(function() {
// Optional: Show a temporary tooltip or change button text
}, function(err) {
console.error('Could not copy text: ', err);
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,580 @@
{% extends "base.html" %}
{% from "_form_helpers.html" import render_field %}
{% 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">Retrieve & Split Data</h1>
<div class="col-lg-8 mx-auto">
<p class="lead mb-4">
Retrieve FHIR bundles from a server, then download as a ZIP file. Split uploaded or retrieved bundle ZIPs into individual resources, downloaded as a ZIP file.
</p>
</div>
</div>
<div class="container mt-4">
<div class="card shadow-sm mb-4">
<div class="card-header">
<h4 class="my-0 fw-normal"><i class="bi bi-cloud-download me-2"></i>Retrieve Bundles</h4>
</div>
<div class="card-body">
{% if form.errors %}
<div class="alert alert-danger">
<p><strong>Please correct the following errors:</strong></p>
<ul>
{% for field, errors in form.errors.items() %}
<li>{{ form[field].label.text }}: {{ errors|join(', ') }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form id="retrieveForm" method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label fw-bold">FHIR Server</label>
<div class="input-group">
<button type="button" class="btn btn-outline-primary" id="toggleServer">
<span id="toggleLabel">Use Local HAPI</span>
</button>
{# Ensure the input field uses the form object's data #}
{{ form.fhir_server_url(class="form-control", id="fhirServerUrl", style="display: none;", placeholder="e.g., https://fhir.hl7.org.au/aucore/fhir/DEFAULT", **{'aria-describedby': 'fhirServerHelp'}) }}
</div>
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
</div>
{# --- Checkbox Row --- #}
<div class="row g-3 mb-3 align-items-center">
<div class="col-md-6">
{# Render Fetch Referenced Resources checkbox using the macro #}
{{ render_field(form.validate_references, id='validate_references_checkbox') }}
</div>
<div class="col-md-6" id="fetchReferenceBundlesGroup" style="display: none;"> {# Initially hidden #}
{# Render NEW Fetch Full Reference Bundles checkbox using the macro #}
{{ render_field(form.fetch_reference_bundles, id='fetch_reference_bundles_checkbox') }}
</div>
</div>
{# --- End Checkbox Row --- #}
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
<div class="banner3 mt-3" id="resourceTypes" style="display: none;">
<h5>Resource Types</h5>
<div class="d-flex flex-wrap gap-2" id="resourceButtons"></div>
</div>
{# Render SubmitField using the form object #}
<button type="submit" class="btn btn-primary btn-lg w-100" id="retrieveButton" name="submit_retrieve">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
<i class="bi bi-arrow-down-circle me-2"></i>{{ form.submit_retrieve.label.text }}
</button>
<button type="button" class="btn btn-success btn-lg w-100 mt-2" id="downloadRetrieveButton" style="display: none;">
<i class="bi bi-download me-2"></i>Download Retrieved Bundles
</button>
</form>
<div class="card mt-4">
<div class="card-header">Retrieval Log</div>
<div class="card-body">
<div id="retrieveConsole" class="border p-3 rounded bg-dark text-light" style="height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
<span class="text-muted">Retrieval output will appear here...</span>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<h4 class="my-0 fw-normal"><i class="bi bi-scissors me-2"></i>Split Bundles</h4>
</div>
<div class="card-body">
<form id="splitForm" method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label fw-bold">Bundle Source</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="bundle_source" id="uploadBundles" value="upload" checked>
<label class="form-check-label" for="uploadBundles">Upload Bundle ZIP</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="bundle_source" id="useRetrievedBundles" value="retrieved" {% if not session.get('retrieve_params', {}).get('bundle_zip_path', None) %} disabled {% endif %}>
<label class="form-check-label" for="useRetrievedBundles">Use Retrieved Bundles{% if not session.get('retrieve_params', {}).get('bundle_zip_path', None) %} (No bundles retrieved in this session){% endif %}</label>
</div>
</div>
{# Render FileField using the macro #}
{{ render_field(form.split_bundle_zip, class="form-control") }}
{# Render SubmitField using the form object #}
<button type="submit" class="btn btn-primary btn-lg w-100" id="splitButton" name="submit_split">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
<i class="bi bi-arrow-right-circle me-2"></i>{{ form.submit_split.label.text }}
</button>
<button type="button" class="btn btn-success btn-lg w-100 mt-2" id="downloadSplitButton" style="display: none;">
<i class="bi bi-download me-2"></i>Download Split Resources
</button>
</form>
<div class="card mt-4">
<div class="card-header">Splitting Log</div>
<div class="card-body">
<div id="splitConsole" class="border p-3 rounded bg-dark text-light" style="height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
<span class="text-muted">Splitting output will appear here...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element References ---
const retrieveForm = document.getElementById('retrieveForm');
const splitForm = document.getElementById('splitForm');
const retrieveButton = document.getElementById('retrieveButton');
const splitButton = document.getElementById('splitButton');
const downloadRetrieveButton = document.getElementById('downloadRetrieveButton');
const downloadSplitButton = document.getElementById('downloadSplitButton');
const fetchMetadataButton = document.getElementById('fetchMetadata');
const toggleServerButton = document.getElementById('toggleServer');
const toggleLabel = document.getElementById('toggleLabel');
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
const resourceTypesDiv = document.getElementById('resourceTypes');
const resourceButtonsContainer = document.getElementById('resourceButtons');
const retrieveConsole = document.getElementById('retrieveConsole');
const splitConsole = document.getElementById('splitConsole');
const bundleSourceRadios = document.getElementsByName('bundle_source');
const splitBundleZipInput = document.getElementById('split_bundle_zip');
const splitBundleZipGroup = splitBundleZipInput ? splitBundleZipInput.closest('.form-group') : null;
const validateReferencesCheckbox = document.getElementById('validate_references_checkbox');
const fetchReferenceBundlesGroup = document.getElementById('fetchReferenceBundlesGroup');
const fetchReferenceBundlesCheckbox = document.getElementById('fetch_reference_bundles_checkbox');
// --- State & Config Variables ---
const appMode = '{{ app_mode | default("standalone") | lower }}';
const apiKey = '{{ api_key }}';
const sessionZipPath = '{{ session.get("retrieve_params", {}).get("bundle_zip_path", "") }}';
let useLocalHapi = (appMode !== 'lite');
let retrieveZipPath = null;
let splitZipPath = null;
let fetchedMetadataCache = null;
// --- Helper Functions ---
const sanitizeText = (str) => str ? String(str).replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
// --- UI Update Functions ---
function updateBundleSourceUI() {
const selectedSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
if (splitBundleZipGroup) {
splitBundleZipGroup.style.display = selectedSource === 'upload' ? 'block' : 'none';
}
if (splitBundleZipInput) {
splitBundleZipInput.required = selectedSource === 'upload';
}
}
function updateServerToggleUI() {
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) return;
if (appMode === 'lite') {
useLocalHapi = false;
toggleServerButton.disabled = true;
toggleServerButton.classList.add('disabled');
toggleServerButton.style.pointerEvents = 'none !important';
toggleServerButton.setAttribute('aria-disabled', 'true');
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
toggleLabel.textContent = 'Use Custom URL';
fhirServerUrlInput.style.display = 'block';
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
fhirServerUrlInput.required = true;
} else {
toggleServerButton.disabled = false;
toggleServerButton.classList.remove('disabled');
toggleServerButton.style.pointerEvents = 'auto';
toggleServerButton.removeAttribute('aria-disabled');
toggleServerButton.title = "Toggle between Local HAPI and Custom URL";
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
fhirServerUrlInput.required = !useLocalHapi;
}
fhirServerUrlInput.classList.remove('is-invalid');
console.log(`Server toggle UI updated: useLocalHapi=${useLocalHapi}, customUrl=${fhirServerUrlInput.value}`);
}
function toggleFetchReferenceBundles() {
if (validateReferencesCheckbox && fetchReferenceBundlesGroup) {
fetchReferenceBundlesGroup.style.display = validateReferencesCheckbox.checked ? 'block' : 'none';
if (!validateReferencesCheckbox.checked && fetchReferenceBundlesCheckbox) {
fetchReferenceBundlesCheckbox.checked = false;
}
} else {
console.warn("Could not find checkbox elements needed for toggling.");
}
}
// --- Event Listeners ---
bundleSourceRadios.forEach(radio => {
radio.addEventListener('change', updateBundleSourceUI);
});
toggleServerButton.addEventListener('click', () => {
if (appMode === 'lite') return;
useLocalHapi = !useLocalHapi;
if (useLocalHapi) fhirServerUrlInput.value = '';
updateServerToggleUI();
resourceTypesDiv.style.display = 'none';
fetchedMetadataCache = null;
console.log(`Server toggled: useLocalHapi=${useLocalHapi}`);
});
if (validateReferencesCheckbox) {
validateReferencesCheckbox.addEventListener('change', toggleFetchReferenceBundles);
} else {
console.warn("Validate references checkbox not found.");
}
fetchMetadataButton.addEventListener('click', async () => {
resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
resourceTypesDiv.style.display = 'block';
const customUrl = useLocalHapi ? null : fhirServerUrlInput.value.trim().replace(/\/+$/, '');
if (!useLocalHapi && !customUrl) {
fhirServerUrlInput.classList.add('is-invalid');
alert('Please enter a valid FHIR server URL.');
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Custom URL required.</span>';
return;
}
if (!useLocalHapi) {
try { new URL(customUrl); } catch (_) {
fhirServerUrlInput.classList.add('is-invalid');
alert('Invalid custom URL format.');
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Invalid URL format.</span>';
return;
}
}
fhirServerUrlInput.classList.remove('is-invalid');
fetchMetadataButton.disabled = true;
fetchMetadataButton.textContent = 'Fetching...';
try {
const fetchUrl = '/fhir/metadata'; // Always target proxy
const headers = { 'Accept': 'application/fhir+json' };
if (!useLocalHapi && customUrl) {
headers['X-Target-FHIR-Server'] = customUrl;
console.log(`Workspaceing metadata via proxy with X-Target-FHIR-Server: ${customUrl}`);
} else {
console.log("Fetching metadata via proxy for local HAPI server");
}
console.log(`Proxy Fetch URL: ${fetchUrl}`);
console.log(`Request Headers sent TO PROXY: ${JSON.stringify(headers)}`);
const response = await fetch(fetchUrl, { method: 'GET', headers: headers });
if (!response.ok) {
let errorText = `Request failed with status ${response.status} ${response.statusText}`;
try { const errorBody = await response.text(); errorText += ` - Body: ${errorBody.substring(0, 200)}...`; } catch (e) {}
console.error(`Metadata fetch failed: ${errorText}`);
throw new Error(errorText);
}
const data = await response.json();
console.log("Metadata received:", JSON.stringify(data, null, 2).substring(0, 500));
fetchedMetadataCache = data;
const resourceTypes = data.rest?.[0]?.resource?.map(r => r.type)?.filter(Boolean) || [];
resourceButtonsContainer.innerHTML = '';
if (!resourceTypes.length) {
resourceButtonsContainer.innerHTML = '<span class="text-warning">No resource types found in metadata.</span>';
} else {
resourceTypes.sort().forEach(type => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-outline-primary btn-sm me-2 mb-2';
button.textContent = type;
button.dataset.resource = type;
button.addEventListener('click', () => {
button.classList.toggle('active');
button.classList.toggle('btn-primary');
button.classList.toggle('btn-outline-primary');
});
resourceButtonsContainer.appendChild(button);
});
}
} catch (e) {
console.error('Metadata fetch error:', e);
resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: ${e.message}</span>`;
resourceTypesDiv.style.display = 'block';
alert(`Error fetching metadata: ${e.message}`);
fetchedMetadataCache = null;
} finally {
fetchMetadataButton.disabled = false;
fetchMetadataButton.textContent = 'Fetch Metadata';
}
});
retrieveForm.addEventListener('submit', async (e) => {
e.preventDefault();
const spinner = retrieveButton.querySelector('.spinner-border');
const icon = retrieveButton.querySelector('i');
retrieveButton.disabled = true;
if (spinner) spinner.style.display = 'inline-block';
if (icon) icon.style.display = 'none';
downloadRetrieveButton.style.display = 'none';
retrieveZipPath = null;
retrieveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle retrieval...</div>`;
const selectedResources = Array.from(resourceButtonsContainer.querySelectorAll('.btn.active')).map(btn => btn.dataset.resource);
if (!selectedResources.length) {
alert('Please select at least one resource type.');
retrieveButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
}
const formData = new FormData();
const csrfTokenInput = retrieveForm.querySelector('input[name="csrf_token"]');
if(csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
selectedResources.forEach(res => formData.append('resources', res));
if (validateReferencesCheckbox) {
formData.append('validate_references', validateReferencesCheckbox.checked ? 'true' : 'false');
}
if (fetchReferenceBundlesCheckbox) {
if (validateReferencesCheckbox && validateReferencesCheckbox.checked) {
formData.append('fetch_reference_bundles', fetchReferenceBundlesCheckbox.checked ? 'true' : 'false');
} else {
formData.append('fetch_reference_bundles', 'false');
}
} else {
formData.append('fetch_reference_bundles', 'false');
}
const currentFhirServerUrl = useLocalHapi ? '/fhir' : fhirServerUrlInput.value.trim();
if (!useLocalHapi && !currentFhirServerUrl) {
alert('Custom FHIR Server URL is required.'); fhirServerUrlInput.classList.add('is-invalid');
retrieveButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
}
formData.append('fhir_server_url', currentFhirServerUrl);
const headers = {
'Accept': 'application/x-ndjson',
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
'X-API-Key': apiKey
};
console.log(`Submitting retrieve request. Server: ${currentFhirServerUrl}, ValidateRefs: ${formData.get('validate_references')}, FetchRefBundles: ${formData.get('fetch_reference_bundles')}`);
try {
const response = await fetch('/api/retrieve-bundles', { method: 'POST', headers: headers, body: formData });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response.' }));
throw new Error(`HTTP ${response.status}: ${errorData.message || 'Unknown error during retrieval'}`);
}
retrieveZipPath = response.headers.get('X-Zip-Path');
console.log(`Received X-Zip-Path: ${retrieveZipPath}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timestamp = new Date().toLocaleTimeString();
let messageClass = 'text-info'; let prefix = '[INFO]';
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
const messageDiv = document.createElement('div');
messageDiv.className = messageClass;
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
retrieveConsole.appendChild(messageDiv);
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
if (data.type === 'complete' && retrieveZipPath) {
const completeData = data.data || {};
const bundlesFound = (completeData.total_initial_bundles > 0) || (completeData.fetched_individual_references > 0) || (completeData.fetched_type_bundles > 0);
if (!bundlesFound) {
retrieveConsole.innerHTML += `<div class="text-warning">${timestamp} [WARNING] No bundles were retrieved. Check server data or resource selection.</div>`;
} else {
downloadRetrieveButton.style.display = 'block';
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
if(useRetrievedRadio) useRetrievedRadio.disabled = false;
if(useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles';
}
}
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
}
}
if (buffer.trim()) { /* Handle final buffer if necessary */ }
} catch (e) {
console.error('Retrieval error:', e);
const ts = new Date().toLocaleTimeString();
retrieveConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
} finally {
retrieveButton.disabled = false;
if (spinner) spinner.style.display = 'none';
if (icon) icon.style.display = 'inline-block';
}
});
downloadRetrieveButton.addEventListener('click', () => {
if (retrieveZipPath) {
const filename = retrieveZipPath.split('/').pop() || 'retrieved_bundles.zip';
const downloadUrl = `/tmp/${filename}`;
console.log(`Attempting to download ZIP from Flask endpoint: ${downloadUrl}`);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'retrieved_bundles.zip';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => {
const csrfToken = retrieveForm.querySelector('input[name="csrf_token"]')?.value || '';
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
.then(() => console.log("Session clear requested."))
.catch(err => console.error("Session clear failed:", err));
downloadRetrieveButton.style.display = 'none';
retrieveZipPath = null;
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
if(useRetrievedRadio) useRetrievedRadio.disabled = true;
if(useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles (No bundles retrieved in this session)';
}, 500);
} else {
console.error("No retrieve ZIP path available for download");
alert("Download error: No file path available.");
}
});
splitForm.addEventListener('submit', async (e) => {
e.preventDefault();
const spinner = splitButton.querySelector('.spinner-border');
const icon = splitButton.querySelector('i');
splitButton.disabled = true;
if (spinner) spinner.style.display = 'inline-block';
if (icon) icon.style.display = 'none';
downloadSplitButton.style.display = 'none';
splitZipPath = null;
splitConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle splitting...</div>`;
const formData = new FormData();
const csrfTokenInput = splitForm.querySelector('input[name="csrf_token"]');
if(csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
const bundleSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
if (bundleSource === 'upload') {
if (!splitBundleZipInput || splitBundleZipInput.files.length === 0) {
alert('Please select a ZIP file to upload for splitting.');
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
}
formData.append('split_bundle_zip', splitBundleZipInput.files[0]);
console.log("Splitting uploaded file:", splitBundleZipInput.files[0].name);
} else if (bundleSource === 'retrieved' && sessionZipPath) {
formData.append('split_bundle_zip_path', sessionZipPath);
console.log("Splitting retrieved bundle path:", sessionZipPath);
} else if (bundleSource === 'retrieved' && !sessionZipPath){
// Check if the retrieveZipPath from the *current* run exists
if (retrieveZipPath) {
formData.append('split_bundle_zip_path', retrieveZipPath);
console.log("Splitting retrieve bundle path from current run:", retrieveZipPath);
} else {
alert('No bundle source available. Please retrieve bundles first or upload a ZIP.');
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
}
} else {
alert('No bundle source selected.');
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
}
const headers = {
'Accept': 'application/x-ndjson',
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
'X-API-Key': apiKey
};
try {
const response = await fetch('/api/split-bundles', { method: 'POST', headers: headers, body: formData });
if (!response.ok) {
const errorMsg = await response.text();
let detail = errorMsg; try { const errorJson = JSON.parse(errorMsg); detail = errorJson.message || errorJson.error || JSON.stringify(errorJson); } catch(e) {}
throw new Error(`HTTP ${response.status}: ${detail}`);
}
splitZipPath = response.headers.get('X-Zip-Path');
console.log(`Received X-Zip-Path for split: ${splitZipPath}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read(); if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n'); buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timestamp = new Date().toLocaleTimeString();
let messageClass = 'text-info'; let prefix = '[INFO]';
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
const messageDiv = document.createElement('div'); messageDiv.className = messageClass; messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`; splitConsole.appendChild(messageDiv); splitConsole.scrollTop = splitConsole.scrollHeight;
if (data.type === 'complete' && splitZipPath) { downloadSplitButton.style.display = 'block'; }
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
}
}
if (buffer.trim()) { /* Handle final buffer */ }
} catch (e) {
console.error('Splitting error:', e);
const ts = new Date().toLocaleTimeString();
splitConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
splitConsole.scrollTop = splitConsole.scrollHeight;
} finally {
splitButton.disabled = false;
if (spinner) spinner.style.display = 'none';
if (icon) icon.style.display = 'inline-block';
}
});
downloadSplitButton.addEventListener('click', () => {
if (splitZipPath) {
const filename = splitZipPath.split('/').pop() || 'split_resources.zip';
const downloadUrl = `/tmp/${filename}`;
console.log(`Attempting to download split ZIP from Flask endpoint: ${downloadUrl}`);
const link = document.createElement('a'); link.href = downloadUrl; link.download = 'split_resources.zip'; document.body.appendChild(link); link.click(); document.body.removeChild(link);
setTimeout(() => {
const csrfToken = splitForm.querySelector('input[name="csrf_token"]')?.value || '';
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
.then(() => console.log("Session clear requested after split download."))
.catch(err => console.error("Session clear failed:", err));
downloadSplitButton.style.display = 'none';
splitZipPath = null;
}, 500);
} else {
console.error("No split ZIP path available for download");
alert("Download error: No file path available.");
}
});
// --- Initial Setup Calls ---
updateBundleSourceUI();
updateServerToggleUI();
toggleFetchReferenceBundles(); // Initial call for the new checkbox
}); // End DOMContentLoaded
</script>
{% endblock %}

View File

@ -0,0 +1,461 @@
{% extends "base.html" %}
{% from "_form_helpers.html" import render_field %}
{% block extra_head %} {# Assuming base.html has an 'extra_head' block for additional head elements #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
{% endblock %}
{% block content %}
{# Main page content for searching and importing packages #}
<div class="container">
{# Flash messages area - Ensure _flash_messages.html exists and is included in base.html or rendered here #}
{% include "_flash_messages.html" %}
{# Display warning if package fetching failed on initial load #}
{% if fetch_failed %}
<div class="alert alert-warning">
Unable to fetch packages from registries. Showing a fallback list. Please try again later or contact support.
</div>
{% endif %}
<div class="row">
{# Left Column: Search Packages Area #}
<div class="col-md-9 mb-4">
<div class="card">
<div class="card-body">
{# Header with Refresh Button #}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-search me-2"></i>Search Packages</h2>
<button class="btn btn-sm btn-outline-warning" id="clear-cache-btn"
title="Clear package cache and fetch fresh list"
hx-post="{{ url_for('refresh_cache_task') }}"
hx-indicator="#cache-spinner"
hx-swap="none"
hx-target="this">
<i class="bi bi-arrow-clockwise"></i> Clear & Refresh Cache
<span id="cache-spinner" class="spinner-border spinner-border-sm ms-1" role="status" aria-hidden="true" style="display: none;"></span>
</button>
</div>
{# Cache Status Timestamp #}
<div class="mb-3 text-muted" style="font-size: 0.85em;">
{% if last_cached_timestamp %}
Package list last fetched: {{ last_cached_timestamp.strftime('%Y-%m-%d %H:%M:%S %Z') if last_cached_timestamp else 'Never' }}
{% if fetch_failed %} (Fetch Failed){% endif %}
{% elif is_fetching %} {# Show text spinner specifically during initial fetch state triggered by backend #}
Fetching package list... <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{% else %}
Never fetched or cache cleared.
{% endif %}
</div>
{# Search Input with HTMX #}
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search packages..." name="search" autofocus
hx-get="{{ url_for('api_search_packages') }}" hx-indicator=".htmx-indicator" hx-target="#search-results" hx-trigger="input changed delay:500ms, search">
<span class="input-group-text htmx-indicator"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
</div>
<div id="search-error" class="alert alert-danger d-none"></div>
{# Search Results Area (populated by HTMX) #}
<div id="search-results">
{% include '_search_results_table.html' %} {# Includes the initial table state or updated results #}
</div>
</div>
</div>
</div>
{# Right Column: Import Form, Log Window, Animation, Warning #}
<div class="col-md-3 mb-4">
<div class="card">
<div class="card-body">
{# Import Form #}
<h2><i class="bi bi-download me-2"></i>Import a New IG</h2>
<form id="import-ig-form"> {# Form ID used by JS #}
{{ form.hidden_tag() }} {# Include CSRF token if using Flask-WTF #}
{{ render_field(form.package_name, class="form-control") }}
{{ render_field(form.package_version, class="form-control") }}
{{ render_field(form.dependency_mode, class="form-select") }}
<div class="d-grid gap-2 d-sm-flex mt-3">
{# Import Button triggers HTMX POST #}
<button class="btn btn-success" id="submit-btn"
hx-post="{{ url_for('import_ig') }}"
hx-indicator="#import-spinner"
hx-include="closest form"
hx-swap="none">
Import
</button>
<span id="import-spinner" class="spinner-border spinner-border-sm align-middle ms-2" role="status" aria-hidden="true" style="display: none;"></span>
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a> {# Simple link back #}
</div>
</form>
{# Live Log Output Window #}
<div class="log-window mt-4">
<h5><i class="bi bi-terminal me-2"></i>Live Log Output</h5>
<div id="log-output" class="log-output">
<p class="text-muted small">Logs from caching or import actions will appear here.</p>
</div>
{# Indicator shown while connecting to SSE #}
<div id="log-loading-indicator" class="mt-2 text-muted small" style="display: none;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Connecting to log stream...
</div>
</div>
{# Animation Window (Hidden by default) #}
<div id="log-animation-window" class="minifire-container" style="display: none;">
{# Status Text Overlay #}
<div id="animation-status-text" class="minifire-status-text"></div>
{# Campfire Animation HTML Structure #}
<div class="minifire-campfire">
<div class="minifire-sparks">
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
<div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div> <div class="minifire-spark"></div>
</div>
<div class="minifire-logs">
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
<div class="minifire-log"><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div><div class="minifire-streak"></div></div>
</div>
<div class="minifire-sticks">
<div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div> <div class="minifire-stick"></div>
</div>
<div class="minifire-fire">
<div class="minifire-fire__red"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__orange"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__yellow"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
<div class="minifire-fire__white"> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> <div class="minifire-flame"></div> </div>
</div>
</div>
</div>
{# Warning Text (Hidden by default) #}
<div id="process-warning-text" class="import-warning-text" style="display: none; margin-top: 1rem;">
DO NOT LEAVE PAGE<br>
UNTIL COMPLETED<br>
!ANIMATION DISAPEARS!
</div>
</div> {# End card-body #}
</div> {# End card #}
</div> {# End col-md-3 #}
</div> {# End row #}
</div> {# End container #}
<style>
/* Internal CSS Styles */
.log-window { margin-top: 1.5rem; }
.log-output {
background-color: #1e2228; border: 1px solid #444; border-radius: 4px; padding: 10px;
height: 250px; overflow-y: auto; font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem; color: #e0e0e0; white-space: pre-wrap; word-wrap: break-word;
}
.log-output p { margin: 0; padding: 1px 0; border-bottom: 1px solid #333; line-height: 1.3; }
.log-output p:last-child { border-bottom: none; }
.log-output .log-timestamp { color: #777; margin-right: 8px; }
.log-output .text-muted.small { color: #888 !important; font-style: italic; border-bottom: none; }
/* Animation Status Text (from fire-animation.css or here) */
.minifire-status-text {
position: absolute; top: 8px; left: 0; width: 100%; text-align: center;
color: #f0f0f0; font-size: 0.85em; font-weight: bold; z-index: 60;
pointer-events: none; text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); line-height: 1.2;
}
/* Warning Text Style */
.import-warning-text {
color: #dc3545; /* Bootstrap danger color */
font-style: italic;
font-weight: bold;
text-align: center;
font-size: 0.9em;
line-height: 1.3;
}
/* Other general styles */
.text-muted { color: #ccc !important; font-size: 1.1rem; margin-top: 1rem; }
.table, .card { background-color: #2a2e34; color: #fff; }
.table th, .table td { border-color: #444; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; opacity: 1; }
#cache-spinner, #import-spinner { vertical-align: text-bottom; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check if HTMX is loaded (essential for button actions)
if (typeof htmx === 'undefined') {
console.error('HTMX is not loaded. Button functionality will be disabled.');
const searchErrorDiv = document.getElementById('search-error');
if(searchErrorDiv) {
searchErrorDiv.textContent = 'Error: Core page functionality failed to load. Refresh or contact support.';
searchErrorDiv.classList.remove('d-none');
}
// Disable interactive elements that rely on HTMX
document.getElementById('clear-cache-btn')?.setAttribute('disabled', 'true');
document.getElementById('submit-btn')?.setAttribute('disabled', 'true');
document.querySelector('input[name="search"]')?.setAttribute('disabled', 'true');
return; // Stop script execution
}
// Get references to frequently used DOM elements
const logOutput = document.getElementById('log-output');
const logLoadingIndicator = document.getElementById('log-loading-indicator');
const logAnimationWindow = document.getElementById('log-animation-window');
const animationStatusText = document.getElementById('animation-status-text');
const processWarningText = document.getElementById('process-warning-text'); // Get warning text element
let currentEventSource = null; // Variable to hold the active Server-Sent Events connection
// --- Helper Functions ---
// Appends a formatted log message to the log output window
function appendLogMessage(message) {
if (!logOutput) return;
const initialMsg = logOutput.querySelector('.text-muted.small'); // Find placeholder
if (initialMsg) initialMsg.remove(); // Remove placeholder on first message
const timestamp = new Date().toLocaleTimeString(); // Get current time for timestamp
const logEntry = document.createElement('p'); // Create paragraph for the log line
const messageText = String(message).replace(/^INFO:services:/, '').replace(/^INFO:app:/, '').trim(); // Clean message
logEntry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span>`; // Add timestamp
logEntry.appendChild(document.createTextNode(messageText)); // Add log message text safely
// Style error/warning messages
if (messageText.startsWith("ERROR:") || messageText.startsWith("CRITICAL ERROR:")) {
logEntry.style.color = "#f8d7da"; logEntry.style.backgroundColor = "#49272a";
} else if (messageText.startsWith("WARNING:")) {
logEntry.style.color = "#fff3cd";
}
logOutput.appendChild(logEntry); // Add the log line to the window
logOutput.scrollTop = logOutput.scrollHeight; // Scroll to the bottom
}
// Clears the log window and resets the placeholder text
function clearLogWindow() {
if (logOutput) logOutput.innerHTML = '<p class="text-muted small">Waiting for logs...</p>';
}
// Shows the animation window and sets the status text
function showAnimation(statusText = '') {
if (logAnimationWindow) logAnimationWindow.style.display = 'flex'; // Use flex to utilize justify/align center for campfire
if (animationStatusText) animationStatusText.textContent = statusText; // Update status text
if (processWarningText) processWarningText.style.display = 'block'; // Show the warning text
}
// Hides the animation window and clears status/warning text
function hideAnimation() {
if (logAnimationWindow) logAnimationWindow.style.display = 'none';
if (animationStatusText) animationStatusText.textContent = '';
if (processWarningText) processWarningText.style.display = 'none'; // Hide the warning text
}
// --- Server-Sent Events (SSE) Setup Function ---
// Connects to the log stream and handles incoming messages
function setupLogStream(onDoneCallback) {
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close existing connection
clearLogWindow(); // Clear previous logs
if (logLoadingIndicator) logLoadingIndicator.style.display = 'block'; // Show "Connecting..."
// Note: showAnimation() is now called by the function initiating the action
try {
currentEventSource = new EventSource("{{ url_for('stream_import_logs') }}"); // Establish connection
// On successful connection opening
currentEventSource.onopen = function() {
console.log("SSE connection opened.");
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide "Connecting..."
// Animation should already be visible here if started correctly
};
// On receiving a message from the server
currentEventSource.onmessage = function(event) {
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none'; // Hide indicator if still visible
// Keep animation visible
// Check for the special [DONE] signal
if (event.data === '[DONE]') {
console.log("SSE stream finished ([DONE] received).");
hideAnimation(); // Hide animation and warning text
const lastLog = logOutput.lastElementChild?.textContent || ""; // Check last log for errors
const hadError = lastLog.includes("ERROR:") || lastLog.includes("CRITICAL ERROR:");
if (!hadError) { appendLogMessage("Operation complete."); } // Log completion if no errors seen
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close SSE
if (onDoneCallback) onDoneCallback(true, hadError); // Trigger callback (success=true)
} else {
appendLogMessage(event.data); // Log the received message
}
};
// On SSE connection error
currentEventSource.onerror = function(err) {
console.error('SSE connection error:', err);
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
hideAnimation(); // Hide animation and warning text on error
appendLogMessage("ERROR: Log streaming connection error or closed prematurely.");
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; }
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
};
} catch (e) { // On error initializing EventSource
console.error('Failed to initialize SSE:', e);
if (logLoadingIndicator) logLoadingIndicator.style.display = 'none';
hideAnimation(); // Hide animation and warning text on failure
appendLogMessage("ERROR: Unable to establish log stream: " + (e.message || 'Unknown Error'));
if (onDoneCallback) onDoneCallback(false, true); // Trigger callback (success=false, error=true)
}
}
// --- Page Load / Initial State Logic ---
// Check if the backend flagged that an initial fetch is happening
const isFetchingInitial = {{ is_fetching | tojson }};
if (isFetchingInitial) {
console.log("Initial fetch detected, setting up log stream.");
showAnimation('Refreshing cache...'); // Show animation and "Refreshing" text immediately
setupLogStream(function(success, streamHadError) {
console.log(`Initial fetch log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation when stream ends
// Backend handles the page update after its fetch finishes
});
}
// --- Event Listeners for Buttons ---
// Listener for "Clear & Refresh Cache" Button (HTMX Triggered)
const clearCacheBtn = document.getElementById('clear-cache-btn');
const cacheSpinner = document.getElementById('cache-spinner');
if (clearCacheBtn) {
// Before HTMX sends the request to start the cache refresh task
clearCacheBtn.addEventListener('htmx:beforeRequest', function(evt) {
console.log("HTMX Clear Cache request starting.");
clearCacheBtn.disabled = true; if(cacheSpinner) cacheSpinner.style.display = 'inline-block';
showAnimation('Refreshing cache...'); // Show animation and set text
// Setup the log stream, providing the callback for when SSE finishes
setupLogStream(function(success, streamHadError) {
// This runs ONLY when the SSE stream sends [DONE] or errors out
console.log(`Clear cache log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation and warning text
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Re-enable button/hide spinner
// Determine message and reload page
if (success && !streamHadError) { appendLogMessage("Cache refresh complete. Reloading page..."); }
else if (success && streamHadError) { appendLogMessage("Cache refresh finished with errors. Reloading page..."); }
else { appendLogMessage("ERROR: Cache refresh failed or log stream error. Reloading page..."); }
setTimeout(() => window.location.reload(), 750); // Reload after slight delay
});
});
// After HTMX gets the *initial* response from the API (should be 202 Accepted)
clearCacheBtn.addEventListener('htmx:afterRequest', function(evt) {
console.log('HTMX Clear Cache API response received. Status:', evt.detail.xhr.status);
if (evt.detail.failed || evt.detail.xhr.status >= 400) {
// The API call itself failed to start the task
hideAnimation(); // Hide animation/warning immediately
console.error("Failed to trigger cache refresh API:", evt.detail);
appendLogMessage(`ERROR: Could not start cache refresh (Server status: ${evt.detail.xhr.status})`);
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none'; // Reset button
// Close SSE stream if it was prematurely opened
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; appendLogMessage("Log stream closed."); }
} else if (evt.detail.xhr.status === 202) {
// Success: Task started in background
appendLogMessage("Cache refresh process started in background...");
// Keep animation and warning text visible, waiting for SSE logs
} else {
// Unexpected success status from API
hideAnimation(); // Hide animation/warning
appendLogMessage(`Warning: Unexpected server response ${evt.detail.xhr.status}. Refresh may not have started.`);
clearCacheBtn.disabled = false; if(cacheSpinner) cacheSpinner.style.display = 'none';
if (currentEventSource) { currentEventSource.close(); currentEventSource = null; } // Close stream
}
});
}
// Listener for Import Button (HTMX Triggered)
const importForm = document.getElementById('import-ig-form');
const importSubmitBtn = document.getElementById('submit-btn');
const importSpinner = document.getElementById('import-spinner');
if (importForm && importSubmitBtn) {
// Before HTMX sends the import request
importSubmitBtn.addEventListener('htmx:beforeRequest', function(evt) {
console.log('HTMX import request starting.');
importSubmitBtn.disabled = true; if(importSpinner) importSpinner.style.display = 'inline-block';
showAnimation('Importing...'); // Show animation and set "Importing..." text
// Setup log stream, callback runs when SSE part finishes
setupLogStream(function(success, streamHadError) {
console.log(`Import log stream ended. Success: ${success}, Stream Error: ${streamHadError}`);
hideAnimation(); // Hide animation/warning when SSE finishes
// Note: Redirect or final status is handled in 'afterRequest' below
if (!success || streamHadError) { appendLogMessage("Warning: Log stream for import finished with errors or failed."); }
});
});
// After HTMX gets the response from the import API call
importSubmitBtn.addEventListener('htmx:afterRequest', function(evt) {
console.log('HTMX import request finished. Status:', evt.detail.xhr.status);
importSubmitBtn.disabled = false; if(importSpinner) importSpinner.style.display = 'none'; // Reset button/spinner
// Ensure animation/warning are hidden if the main request fails quickly
if (evt.detail.failed) hideAnimation();
// Check SSE state (though it should manage its own closure)
if (currentEventSource) console.warn("SSE connection might still be open after import HTMX request completed.");
// Handle failed import API request
if (evt.detail.failed) {
appendLogMessage(`ERROR: Import request failed (Status: ${evt.detail.xhr.status})`);
try { const errorData = JSON.parse(evt.detail.xhr.responseText); if (errorData?.message) appendLogMessage(` -> ${errorData.message}`); } catch(e) {}
} else {
// Handle successful import API request (parse JSON response for status/redirect)
try {
const data = JSON.parse(evt.detail.xhr.responseText);
console.log("Import response data:", data);
// --- REDIRECT/STATUS LOGIC ---
if (data?.status === 'success' && data.redirect) {
appendLogMessage("Import successful. Redirecting...");
setTimeout(() => { window.location.href = data.redirect; }, 1000); // Redirect after 1s
} else if (data?.status === 'warning') {
appendLogMessage(`Warning: ${data.message || 'Import partially successful.'}`); htmx.process(document.body); // Process flash messages if swapped
} else if (data?.status === 'error') {
appendLogMessage(`ERROR: ${data.message || 'Import failed.'}`); htmx.process(document.body); // Process flash messages if swapped
} else {
appendLogMessage("Import request completed (unexpected response format)."); htmx.process(document.body); // Process flash messages if swapped
}
// --- END REDIRECT/STATUS LOGIC ---
} catch (e) { // Handle JSON parsing error
console.error("Error parsing import response JSON:", e);
appendLogMessage("ERROR: Could not process server response after import.");
}
}
});
}
// --- HTMX Search Event Listener (No changes needed) ---
htmx.on('htmx:afterRequest', function(evt) {
if (evt.detail.requestConfig?.path?.includes('api/search-packages')) { /* ... existing search error handling ... */ }
});
htmx.on('htmx:afterSwap', function(evt) { console.log("HTMX content swapped for target:", evt.detail.target.id); });
// --- Page Unload Cleanup ---
// Close SSE connection if user navigates away or closes tab
window.addEventListener('beforeunload', () => {
if (currentEventSource) {
console.log("Page unloading, closing SSE connection.");
currentEventSource.close();
currentEventSource = null;
}
});
}); // End DOMContentLoaded
</script>
{% endblock %}