mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-14 16:19:59 +00:00
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:
parent
cd06b1b216
commit
ca8668e5e5
@ -29,6 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY services.py .
|
||||
COPY forms.py .
|
||||
COPY package.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
COPY tests/ tests/
|
||||
|
@ -148,4 +148,4 @@ FHIRVINE’s core functionality (OAuth2 proxy, app registration) will be integra
|
||||
|
||||
- **Route Conflicts**: Ensure no overlapping routes between FHIRFLARE and FHIRVINE.
|
||||
- **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.
|
@ -16,5 +16,5 @@ services:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=standalone
|
||||
- APP_MODE=lite
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
|
55
forms.py
55
forms.py
@ -1,15 +1,68 @@
|
||||
# forms.py
|
||||
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 flask import request # Import request for file validation in FSHConverterForm
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import logging # Import logging
|
||||
import os
|
||||
|
||||
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
|
||||
class IgImportForm(FlaskForm):
|
||||
"""Form for importing Implementation Guides."""
|
||||
|
Binary file not shown.
@ -18,5 +18,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:12.567847+00:00"
|
||||
"timestamp": "2025-05-04T12:29:17.475734+00:00"
|
||||
}
|
@ -30,5 +30,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:55:42.789700+00:00"
|
||||
"timestamp": "2025-05-04T12:29:15.067826+00:00"
|
||||
}
|
@ -5,5 +5,5 @@
|
||||
"imported_dependencies": [],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:55:57.375318+00:00"
|
||||
"timestamp": "2025-05-04T12:29:16.477868+00:00"
|
||||
}
|
@ -10,5 +10,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:08.781015+00:00"
|
||||
"timestamp": "2025-05-04T12:29:17.363719+00:00"
|
||||
}
|
@ -18,5 +18,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:16.763567+00:00"
|
||||
"timestamp": "2025-05-04T12:29:17.590266+00:00"
|
||||
}
|
@ -10,5 +10,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:23.123899+00:00"
|
||||
"timestamp": "2025-05-04T12:29:18.256800+00:00"
|
||||
}
|
@ -14,5 +14,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:14.378183+00:00"
|
||||
"timestamp": "2025-05-04T12:29:17.529611+00:00"
|
||||
}
|
@ -10,5 +10,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:21.577007+00:00"
|
||||
"timestamp": "2025-05-04T12:29:18.216757+00:00"
|
||||
}
|
@ -10,5 +10,5 @@
|
||||
],
|
||||
"complies_with_profiles": [],
|
||||
"imposed_profiles": [],
|
||||
"timestamp": "2025-04-30T10:56:04.853495+00:00"
|
||||
"timestamp": "2025-05-04T12:29:17.148041+00:00"
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
#FileLock
|
||||
#Wed Apr 30 12:04:24 UTC 2025
|
||||
server=172.19.0.2\:43507
|
||||
hostName=999cce957cc1
|
||||
#Sun May 04 12:29:20 UTC 2025
|
||||
server=172.18.0.2\:34351
|
||||
hostName=1913c9e2ec9b
|
||||
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
123
package.py
Normal 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)
|
@ -5,5 +5,9 @@ requests==2.31.0
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.2
|
||||
Pytest
|
||||
pyyaml==6.0.1
|
||||
fhir.resources==8.0.0
|
||||
Flask-Migrate==4.1.0
|
||||
Flask-Migrate==4.1.0
|
||||
cachetools
|
||||
beautifulsoup4
|
||||
feedparser==6.0.11
|
887
services.py
887
services.py
@ -6,11 +6,13 @@ import re
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
import feedparser
|
||||
from flask import current_app, Blueprint, request, jsonify
|
||||
from fhirpathpy import evaluate
|
||||
from collections import defaultdict, deque
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlparse
|
||||
from types import SimpleNamespace
|
||||
import datetime
|
||||
import subprocess
|
||||
import tempfile
|
||||
@ -29,18 +31,44 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# --- ADD fhir.resources imports ---
|
||||
try:
|
||||
from fhir.resources import construct
|
||||
from fhir.resources.core.exceptions import FHIRValidationError
|
||||
from fhir.resources import get_fhir_model_class
|
||||
from fhir.resources.fhirtypesvalidators import FHIRValidationError # Updated import path
|
||||
FHIR_RESOURCES_AVAILABLE = True
|
||||
logger.info("fhir.resources library found. XML parsing will use this.")
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
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
|
||||
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 ---
|
||||
|
||||
# --- 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 ---
|
||||
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
|
||||
DOWNLOAD_DIR_NAME = "fhir_packages"
|
||||
@ -88,6 +116,469 @@ FHIR_R4_BASE_TYPES = {
|
||||
"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 ---
|
||||
|
||||
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)
|
||||
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):
|
||||
"""Basic sanitization for name/version parts of filename."""
|
||||
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.")
|
||||
temp_dict = basic_fhir_xml_to_dict(content)
|
||||
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)
|
||||
if 'id' in 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 ---
|
||||
|
||||
# --- 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 ---
|
||||
if __name__ == '__main__':
|
||||
|
1
static/animations/import-fire.json
Normal file
1
static/animations/import-fire.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,8 +1,12 @@
|
||||
body {
|
||||
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;
|
||||
height: 100vh;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Fire animation overlay */
|
||||
|
@ -1,225 +1,223 @@
|
||||
/* Scoped to .fire-loading-overlay to prevent conflicts */
|
||||
.fire-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(39, 5, 55, 0.8); /* Semi-transparent gradient background */
|
||||
/* /app/static/css/fire-animation.css */
|
||||
|
||||
/* Removed html, body, stage styles */
|
||||
|
||||
.minifire-container { /* Add a wrapper for positioning/sizing */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050; /* Above Bootstrap modals */
|
||||
}
|
||||
|
||||
.fire-loading-overlay .campfire {
|
||||
position: relative;
|
||||
width: 400px; /* Reduced size for better fit */
|
||||
height: 400px;
|
||||
transform-origin: center center;
|
||||
transform: scale(0.6); /* Scaled down for responsiveness */
|
||||
height: 130px; /* Overall height of the animation area */
|
||||
overflow: hidden; /* Hide parts extending beyond the container */
|
||||
background-color: #270537; /* Optional: Add a background */
|
||||
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;
|
||||
width: 238px;
|
||||
height: 70px;
|
||||
border-radius: 32px;
|
||||
width: 60px; /* 238/4 */
|
||||
height: 18px; /* 70/4 */
|
||||
border-radius: 8px; /* 32/4 */
|
||||
background: #781e20;
|
||||
overflow: hidden;
|
||||
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: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 35px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 32px;
|
||||
left: 9px; /* 35/4 */
|
||||
width: 2px; /* 8/4 */
|
||||
height: 2px; /* 8/4 */
|
||||
border-radius: 8px; /* 32/4 */
|
||||
background: #b35050;
|
||||
transform: translate(-50%, -50%);
|
||||
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;
|
||||
height: 2px;
|
||||
border-radius: 20px;
|
||||
height: 1px; /* Min height */
|
||||
border-radius: 5px; /* 20/4 */
|
||||
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) */
|
||||
.fire-loading-overlay .streak:nth-child(1) { top: 10px; width: 90px; }
|
||||
.fire-loading-overlay .streak:nth-child(2) { top: 10px; left: 100px; width: 80px; }
|
||||
.fire-loading-overlay .streak:nth-child(3) { top: 10px; left: 190px; width: 30px; }
|
||||
.fire-loading-overlay .streak:nth-child(4) { top: 22px; width: 132px; }
|
||||
.fire-loading-overlay .streak:nth-child(5) { top: 22px; left: 142px; width: 48px; }
|
||||
.fire-loading-overlay .streak:nth-child(6) { top: 22px; left: 200px; width: 28px; }
|
||||
.fire-loading-overlay .streak:nth-child(7) { top: 34px; left: 74px; width: 160px; }
|
||||
.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; }
|
||||
/* Scaled Log Positions (Relative to 150px campfire) */
|
||||
.minifire-log:nth-child(1) { bottom: 25px; left: 25px; transform: rotate(150deg) scaleX(0.75); z-index: 20; } /* 100/4, 100/4 */
|
||||
.minifire-log:nth-child(2) { bottom: 30px; left: 35px; transform: rotate(110deg) scaleX(0.75); z-index: 10; } /* 120/4, 140/4 */
|
||||
.minifire-log:nth-child(3) { bottom: 25px; left: 17px; transform: rotate(-10deg) scaleX(0.75); } /* 98/4, 68/4 */
|
||||
.minifire-log:nth-child(4) { bottom: 20px; left: 55px; transform: rotate(-120deg) scaleX(0.75); z-index: 26; } /* 80/4, 220/4 */
|
||||
.minifire-log:nth-child(5) { bottom: 19px; left: 53px; transform: rotate(-30deg) scaleX(0.75); z-index: 25; } /* 75/4, 210/4 */
|
||||
.minifire-log:nth-child(6) { bottom: 23px; left: 70px; transform: rotate(35deg) scaleX(0.85); z-index: 30; } /* 92/4, 280/4 */
|
||||
.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 .log {
|
||||
transform-origin: center center;
|
||||
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 {
|
||||
/* --- Scaled Down Sticks --- */
|
||||
.minifire-stick {
|
||||
position: absolute;
|
||||
width: 68px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 2px 1px rgba(0,0,0,0.1);
|
||||
width: 17px; /* 68/4 */
|
||||
height: 5px; /* 20/4 */
|
||||
border-radius: 3px; /* 10/4 */
|
||||
box-shadow: 0 0 1px 0.5px rgba(0,0,0,0.1);
|
||||
background: #781e20;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.fire-loading-overlay .stick:before {
|
||||
.minifire-stick:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 30px;
|
||||
width: 6px;
|
||||
height: 20px;
|
||||
left: 7px; /* 30/4 -> 7.5 */
|
||||
width: 1.5px; /* 6/4 */
|
||||
height: 5px; /* 20/4 */
|
||||
background: #781e20;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px; /* 10/4 */
|
||||
transform: translateY(50%) rotate(32deg);
|
||||
}
|
||||
|
||||
.fire-loading-overlay .stick:after {
|
||||
.minifire-stick:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 5px; /* 20/4 */
|
||||
height: 5px; /* 20/4 */
|
||||
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 {
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* --- Scaled Down Fire --- */
|
||||
.minifire-fire .minifire-flame {
|
||||
position: absolute;
|
||||
transform-origin: bottom center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fire-loading-overlay .fire__red .flame {
|
||||
width: 48px;
|
||||
border-radius: 48px;
|
||||
/* Red Flames */
|
||||
.minifire-fire__red .minifire-flame {
|
||||
width: 12px; /* 48/4 */
|
||||
border-radius: 12px; /* 48/4 */
|
||||
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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.fire-loading-overlay .fire__red .flame:nth-child(4) { left: 282px; height: 360px; bottom: 100px; animation: fire 2s 0s ease-in-out infinite alternate; }
|
||||
.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);
|
||||
/* Orange Flames */
|
||||
.minifire-fire__orange .minifire-flame {
|
||||
width: 12px; border-radius: 12px; background: #ff9c00;
|
||||
box-shadow: 0 0 20px 5px 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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);
|
||||
/* Yellow Flames */
|
||||
.minifire-fire__yellow .minifire-flame {
|
||||
width: 12px; border-radius: 12px; background: #ffeb6e;
|
||||
box-shadow: 0 0 20px 5px 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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);
|
||||
/* White Flames */
|
||||
.minifire-fire__white .minifire-flame {
|
||||
width: 12px; border-radius: 12px; background: #fef1d9;
|
||||
box-shadow: 0 0 20px 5px 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; }
|
||||
.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; }
|
||||
.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 {
|
||||
/* --- Scaled Down Sparks --- */
|
||||
.minifire-spark {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 20px;
|
||||
width: 1.5px; /* 6/4 */
|
||||
height: 5px; /* 20/4 */
|
||||
background: #fef1d9;
|
||||
border-radius: 18px;
|
||||
border-radius: 5px; /* 18/4 -> 4.5 */
|
||||
z-index: 50;
|
||||
transform-origin: bottom center;
|
||||
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; }
|
||||
.fire-loading-overlay .spark:nth-child(2) { left: 180px; bottom: 240px; animation: spark 1s 1s linear infinite; }
|
||||
.fire-loading-overlay .spark:nth-child(3) { left: 208px; bottom: 320px; animation: spark 1s 0.8s linear infinite; }
|
||||
.fire-loading-overlay .spark:nth-child(4) { left: 310px; bottom: 400px; animation: spark 1s 2s linear infinite; }
|
||||
.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 (Rename to avoid conflicts) --- */
|
||||
/* Use the same keyframe logic, just rename them */
|
||||
@keyframes minifire-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 spark {
|
||||
@keyframes minifire-spark {
|
||||
0%, 35% { transform: scaleY(0) translateY(0); opacity: 0; }
|
||||
50% { transform: scaleY(1) translateY(0); opacity: 1; }
|
||||
70% { transform: scaleY(1) translateY(-10px); opacity: 1; }
|
||||
75% { transform: scaleY(1) translateY(-10px); opacity: 0; }
|
||||
/* Adjusted translateY for smaller scale */
|
||||
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; }
|
||||
}
|
1
static/js/htmx.min.js
vendored
Normal file
1
static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
templates/_flash_messages.html
Normal file
24
templates/_flash_messages.html
Normal 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 %}
|
113
templates/_search_results_table.html
Normal file
113
templates/_search_results_table.html
Normal 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">«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">«</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">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">»</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 %}
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="{% if request.cookies.get('theme') == 'dark' %}dark{% else %}light{% endif %}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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="stylesheet" href="{{ url_for('static', filename='css/fire-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>
|
||||
<style>
|
||||
/* Prevent flash of unstyled content */
|
||||
:root { visibility: hidden; }
|
||||
[data-theme="dark"], [data-theme="light"] { visibility: visible; }
|
||||
|
||||
/* Default (Light Theme) Styles */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
@ -88,7 +93,7 @@
|
||||
height: 1.25rem !important;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block !important;
|
||||
border: 1px solide #6c757d;
|
||||
border: 1px solid #6c757d;
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: #007bff;
|
||||
@ -289,20 +294,18 @@
|
||||
display: block; /* Make code fill pre */
|
||||
}
|
||||
|
||||
|
||||
/* Dark Theme Override */
|
||||
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 */
|
||||
color: #f8f9fa; /* Dark theme text */
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #fsh-output pre code,
|
||||
html[data-theme="dark"] ._fsh_output pre code {
|
||||
color: inherit;
|
||||
}
|
||||
html[data-theme="dark"] #fsh-output pre code,
|
||||
html[data-theme="dark"] ._fsh_output pre code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* --- 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: red"] { color: #e63946 !important; }
|
||||
|
||||
|
||||
/* Specific Action Buttons (Success, Danger, Warning, Info) */
|
||||
.btn-success { background-color: #198754; border-color: #198754; color: white; }
|
||||
.btn-success:hover { background-color: #157347; border-color: #146c43; }
|
||||
@ -752,7 +754,7 @@
|
||||
}
|
||||
</style>
|
||||
</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">
|
||||
<div class="container-fluid">
|
||||
<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>
|
||||
</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>
|
||||
<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 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>
|
||||
<!-- <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>
|
||||
</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 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>
|
||||
@ -779,6 +784,9 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@ -788,6 +796,11 @@
|
||||
<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>
|
||||
</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>
|
||||
<div class="navbar-controls d-flex align-items-center">
|
||||
<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>
|
||||
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() {
|
||||
const html = document.documentElement;
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
const sunIcon = document.querySelector('.fa-sun');
|
||||
const moonIcon = document.querySelector('.fa-moon');
|
||||
if (toggle.checked) {
|
||||
html.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
sunIcon.classList.add('d-none');
|
||||
moonIcon.classList.remove('d-none');
|
||||
} else {
|
||||
html.removeAttribute('data-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
sunIcon.classList.remove('d-none');
|
||||
moonIcon.classList.add('d-none');
|
||||
}
|
||||
const newTheme = toggle.checked ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
sunIcon.classList.toggle('d-none', toggle.checked);
|
||||
moonIcon.classList.toggle('d-none', !toggle.checked);
|
||||
document.cookie = `theme=${newTheme}; path=/; max-age=31536000`;
|
||||
if (typeof loadLottieAnimation === 'function') {
|
||||
const newTheme = toggle.checked ? 'dark' : 'light';
|
||||
loadLottieAnimation(newTheme);
|
||||
}
|
||||
// Set cookie to persist theme
|
||||
document.cookie = `theme=${toggle.checked ? 'dark' : 'light'}; path=/; max-age=31536000`;
|
||||
}
|
||||
|
||||
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 sunIcon = document.querySelector('.fa-sun');
|
||||
const moonIcon = document.querySelector('.fa-moon');
|
||||
if (savedTheme === 'dark' && themeToggle) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
themeToggle.checked = true;
|
||||
sunIcon.classList.add('d-none');
|
||||
moonIcon.classList.remove('d-none');
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
themeToggle.checked = savedTheme === 'dark';
|
||||
sunIcon.classList.toggle('d-none', savedTheme === 'dark');
|
||||
moonIcon.classList.toggle('d-none', savedTheme !== 'dark');
|
||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltips.forEach(t => new bootstrap.Tooltip(t));
|
||||
});
|
||||
|
234
templates/config_hapi.html
Normal file
234
templates/config_hapi.html
Normal 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 %}
|
@ -12,149 +12,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fire Animation Loading Overlay -->
|
||||
<div id="fire-loading-overlay" class="fire-loading-overlay d-none">
|
||||
<div class="campfire">
|
||||
<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>
|
||||
<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 class="campfire-scaler">
|
||||
<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>
|
||||
<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 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 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 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 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="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 class="sticks">
|
||||
<div class="stick"></div><div class="stick"></div><div class="stick"></div><div class="stick"></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 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 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 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">
|
||||
</div> </div> <div class="loading-text text-center">
|
||||
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
|
||||
<p id="log-display" class="text-white mb-0"></p>
|
||||
</div>
|
||||
@ -183,82 +66,80 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('import-ig-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const loadingOverlay = document.getElementById('fire-loading-overlay');
|
||||
const logDisplay = document.getElementById('log-display');
|
||||
// Your existing JS here (exactly as provided before)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('import-ig-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const loadingOverlay = document.getElementById('fire-loading-overlay');
|
||||
const logDisplay = document.getElementById('log-display');
|
||||
|
||||
if (form && submitBtn && loadingOverlay && logDisplay) {
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
submitBtn.disabled = true;
|
||||
logDisplay.textContent = 'Starting import...';
|
||||
loadingOverlay.classList.remove('d-none');
|
||||
if (form && submitBtn && loadingOverlay && logDisplay) {
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
submitBtn.disabled = true;
|
||||
logDisplay.textContent = 'Starting import...';
|
||||
loadingOverlay.classList.remove('d-none'); // Show the overlay
|
||||
|
||||
let eventSource;
|
||||
try {
|
||||
// Start SSE connection for logs
|
||||
eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
|
||||
let eventSource;
|
||||
try {
|
||||
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) {
|
||||
if (event.data === '[DONE]') {
|
||||
const formData = new FormData(form);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
logDisplay.textContent = event.data;
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.error('SSE connection error');
|
||||
eventSource.close();
|
||||
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;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: `HTTP error: ${response.status}` }));
|
||||
throw new Error(errorData.message || `HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
// Submit form via AJAX
|
||||
const formData = new FormData(form);
|
||||
try {
|
||||
const response = await fetch('{{ url_for('import_ig') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (eventSource) 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 data = await response.json();
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
// Optional: display success message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||
eventSource.close();
|
||||
}
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-success mt-3';
|
||||
alertDiv.textContent = data.message || 'Import successful!';
|
||||
form.parentElement.appendChild(alertDiv);
|
||||
alertDiv.className = 'alert alert-danger mt-3';
|
||||
alertDiv.textContent = `Import failed: ${error.message}`;
|
||||
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);
|
||||
if (eventSource) eventSource.close();
|
||||
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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('Required elements missing for import script.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,5 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block body_class %}fire-animation-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 my-5 text-center">
|
||||
<div id="logo-container" class="mb-4">
|
||||
@ -59,7 +61,8 @@
|
||||
<h5 class="card-title">IG Management</h5>
|
||||
<p class="card-text">Import and manage FHIR Implementation Guides.</p>
|
||||
<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('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
|
||||
</div>
|
||||
@ -75,6 +78,7 @@
|
||||
<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('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>
|
||||
|
7
templates/package.canonicals.html
Normal file
7
templates/package.canonicals.html
Normal 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>
|
31
templates/package.dependents.html
Normal file
31
templates/package.dependents.html
Normal 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 %}
|
22
templates/package.logs.html
Normal file
22
templates/package.logs.html
Normal 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 %}
|
16
templates/package.problems.html
Normal file
16
templates/package.problems.html
Normal 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>
|
124
templates/package_details.html
Normal file
124
templates/package_details.html
Normal 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 %}
|
580
templates/retrieve_split_data.html
Normal file
580
templates/retrieve_split_data.html
Normal 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, "<").replace(/>/g, ">") : "";
|
||||
|
||||
// --- 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 %}
|
461
templates/search_and_import_ig.html
Normal file
461
templates/search_and_import_ig.html
Normal 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 %}
|
Loading…
x
Reference in New Issue
Block a user