mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-14 12:10:03 +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 app.py .
|
||||||
COPY services.py .
|
COPY services.py .
|
||||||
COPY forms.py .
|
COPY forms.py .
|
||||||
|
COPY package.py .
|
||||||
COPY templates/ templates/
|
COPY templates/ templates/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
COPY tests/ tests/
|
COPY tests/ tests/
|
||||||
|
@ -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.
|
- **Route Conflicts**: Ensure no overlapping routes between FHIRFLARE and FHIRVINE.
|
||||||
- **Database Issues**: Verify `SQLALCHEMY_DATABASE_URI` points to the same database.
|
- **Database Issues**: Verify `SQLALCHEMY_DATABASE_URI` points to the same database.
|
||||||
- **Logs**: Check `flask run` logs for errors......
|
- **Logs**: Check `flask run` logs for errors.
|
@ -16,5 +16,5 @@ services:
|
|||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_ENV=development
|
- FLASK_ENV=development
|
||||||
- NODE_PATH=/usr/lib/node_modules
|
- NODE_PATH=/usr/lib/node_modules
|
||||||
- APP_MODE=standalone
|
- APP_MODE=lite
|
||||||
command: supervisord -c /etc/supervisord.conf
|
command: supervisord -c /etc/supervisord.conf
|
||||||
|
55
forms.py
55
forms.py
@ -1,15 +1,68 @@
|
|||||||
# forms.py
|
# forms.py
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField
|
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField
|
||||||
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
|
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
|
||||||
from flask import request # Import request for file validation in FSHConverterForm
|
from flask import request # Import request for file validation in FSHConverterForm
|
||||||
import json
|
import json
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
import re
|
import re
|
||||||
import logging # Import logging
|
import logging # Import logging
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger(__name__) # Setup logger if needed elsewhere
|
logger = logging.getLogger(__name__) # Setup logger if needed elsewhere
|
||||||
|
|
||||||
|
# Existing form classes (IgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm) remain unchanged
|
||||||
|
# Only providing RetrieveSplitDataForm
|
||||||
|
class RetrieveSplitDataForm(FlaskForm):
|
||||||
|
"""Form for retrieving FHIR bundles and splitting them into individual resources."""
|
||||||
|
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
|
||||||
|
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
|
||||||
|
|
||||||
|
validate_references = BooleanField('Fetch Referenced Resources', default=False, # Changed label slightly
|
||||||
|
description="If checked, fetches resources referenced by the initial bundles.")
|
||||||
|
|
||||||
|
# --- NEW FIELD ---
|
||||||
|
fetch_reference_bundles = BooleanField('Fetch Full Reference Bundles (instead of individual resources)', default=False,
|
||||||
|
description="Requires 'Fetch Referenced Resources'. Fetches e.g. /Patient instead of Patient/id for each reference.",
|
||||||
|
render_kw={'data-dependency': 'validate_references'}) # Add data attribute for JS
|
||||||
|
# --- END NEW FIELD ---
|
||||||
|
|
||||||
|
split_bundle_zip = FileField('Upload Bundles to Split (ZIP)', validators=[Optional()],
|
||||||
|
render_kw={'accept': '.zip'})
|
||||||
|
submit_retrieve = SubmitField('Retrieve Bundles')
|
||||||
|
submit_split = SubmitField('Split Bundles')
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None):
|
||||||
|
"""Custom validation for RetrieveSplitDataForm."""
|
||||||
|
if not super().validate(extra_validators):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- NEW VALIDATION LOGIC ---
|
||||||
|
# Ensure fetch_reference_bundles is only checked if validate_references is also checked
|
||||||
|
if self.fetch_reference_bundles.data and not self.validate_references.data:
|
||||||
|
self.fetch_reference_bundles.errors.append('Cannot fetch full reference bundles unless "Fetch Referenced Resources" is also checked.')
|
||||||
|
return False
|
||||||
|
# --- END NEW VALIDATION LOGIC ---
|
||||||
|
|
||||||
|
# Validate based on which submit button was pressed
|
||||||
|
if self.submit_retrieve.data:
|
||||||
|
# No specific validation needed here now, handled by URL validator and JS
|
||||||
|
pass
|
||||||
|
elif self.submit_split.data:
|
||||||
|
# Need to check bundle source radio button selection in backend/JS,
|
||||||
|
# but validate file if 'upload' is selected.
|
||||||
|
# This validation might need refinement based on how source is handled.
|
||||||
|
# Assuming 'split_bundle_zip' is only required if 'upload' source is chosen.
|
||||||
|
pass # Basic validation done by Optional() and file type checks below
|
||||||
|
|
||||||
|
# Validate file uploads (keep existing)
|
||||||
|
if self.split_bundle_zip.data:
|
||||||
|
if not self.split_bundle_zip.data.filename.lower().endswith('.zip'):
|
||||||
|
self.split_bundle_zip.errors.append('File must be a ZIP file.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
# Existing forms (IgImportForm, ValidationForm) remain unchanged
|
# Existing forms (IgImportForm, ValidationForm) remain unchanged
|
||||||
class IgImportForm(FlaskForm):
|
class IgImportForm(FlaskForm):
|
||||||
"""Form for importing Implementation Guides."""
|
"""Form for importing Implementation Guides."""
|
||||||
|
Binary file not shown.
@ -18,5 +18,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:12.567847+00:00"
|
"timestamp": "2025-05-04T12:29:17.475734+00:00"
|
||||||
}
|
}
|
@ -30,5 +30,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:55:42.789700+00:00"
|
"timestamp": "2025-05-04T12:29:15.067826+00:00"
|
||||||
}
|
}
|
@ -5,5 +5,5 @@
|
|||||||
"imported_dependencies": [],
|
"imported_dependencies": [],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:55:57.375318+00:00"
|
"timestamp": "2025-05-04T12:29:16.477868+00:00"
|
||||||
}
|
}
|
@ -10,5 +10,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:08.781015+00:00"
|
"timestamp": "2025-05-04T12:29:17.363719+00:00"
|
||||||
}
|
}
|
@ -18,5 +18,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:16.763567+00:00"
|
"timestamp": "2025-05-04T12:29:17.590266+00:00"
|
||||||
}
|
}
|
@ -10,5 +10,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:23.123899+00:00"
|
"timestamp": "2025-05-04T12:29:18.256800+00:00"
|
||||||
}
|
}
|
@ -14,5 +14,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:14.378183+00:00"
|
"timestamp": "2025-05-04T12:29:17.529611+00:00"
|
||||||
}
|
}
|
@ -10,5 +10,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:21.577007+00:00"
|
"timestamp": "2025-05-04T12:29:18.216757+00:00"
|
||||||
}
|
}
|
@ -10,5 +10,5 @@
|
|||||||
],
|
],
|
||||||
"complies_with_profiles": [],
|
"complies_with_profiles": [],
|
||||||
"imposed_profiles": [],
|
"imposed_profiles": [],
|
||||||
"timestamp": "2025-04-30T10:56:04.853495+00:00"
|
"timestamp": "2025-05-04T12:29:17.148041+00:00"
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
#FileLock
|
#FileLock
|
||||||
#Wed Apr 30 12:04:24 UTC 2025
|
#Sun May 04 12:29:20 UTC 2025
|
||||||
server=172.19.0.2\:43507
|
server=172.18.0.2\:34351
|
||||||
hostName=999cce957cc1
|
hostName=1913c9e2ec9b
|
||||||
method=file
|
method=file
|
||||||
id=196869576a9d4c77e61cf01b462a28e4b182a3e00bd
|
id=1969b45b76c42f20115290bfabb203a60dc75365e9d
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
123
package.py
Normal file
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
|
Flask-WTF==1.2.1
|
||||||
WTForms==3.1.2
|
WTForms==3.1.2
|
||||||
Pytest
|
Pytest
|
||||||
|
pyyaml==6.0.1
|
||||||
fhir.resources==8.0.0
|
fhir.resources==8.0.0
|
||||||
Flask-Migrate==4.1.0
|
Flask-Migrate==4.1.0
|
||||||
|
cachetools
|
||||||
|
beautifulsoup4
|
||||||
|
feedparser==6.0.11
|
887
services.py
887
services.py
@ -6,11 +6,13 @@ import re
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import feedparser
|
||||||
from flask import current_app, Blueprint, request, jsonify
|
from flask import current_app, Blueprint, request, jsonify
|
||||||
from fhirpathpy import evaluate
|
from fhirpathpy import evaluate
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
from types import SimpleNamespace
|
||||||
import datetime
|
import datetime
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@ -29,18 +31,44 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# --- ADD fhir.resources imports ---
|
# --- ADD fhir.resources imports ---
|
||||||
try:
|
try:
|
||||||
from fhir.resources import construct
|
from fhir.resources import get_fhir_model_class
|
||||||
from fhir.resources.core.exceptions import FHIRValidationError
|
from fhir.resources.fhirtypesvalidators import FHIRValidationError # Updated import path
|
||||||
FHIR_RESOURCES_AVAILABLE = True
|
FHIR_RESOURCES_AVAILABLE = True
|
||||||
logger.info("fhir.resources library found. XML parsing will use this.")
|
logger.info("fhir.resources library found. XML parsing will use this.")
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
FHIR_RESOURCES_AVAILABLE = False
|
FHIR_RESOURCES_AVAILABLE = False
|
||||||
logger.warning("fhir.resources library not found. XML parsing will be basic and dependency analysis for XML may be incomplete.")
|
logger.warning(f"fhir.resources library failed to import. XML parsing will be basic and dependency analysis for XML may be incomplete. Error: {str(e)}")
|
||||||
# Define dummy classes if library not found to avoid NameErrors later
|
# Define dummy classes if library not found to avoid NameErrors later
|
||||||
class FHIRValidationError(Exception): pass
|
class FHIRValidationError(Exception): pass
|
||||||
def construct(resource_type, data): raise NotImplementedError("fhir.resources not installed")
|
def get_fhir_model_class(resource_type): raise NotImplementedError("fhir.resources not installed")
|
||||||
|
except Exception as e:
|
||||||
|
FHIR_RESOURCES_AVAILABLE = False
|
||||||
|
logger.error(f"Unexpected error importing fhir.resources library: {str(e)}")
|
||||||
|
class FHIRValidationError(Exception): pass
|
||||||
|
def get_fhir_model_class(resource_type): raise NotImplementedError("fhir.resources not installed")
|
||||||
# --- END fhir.resources imports ---
|
# --- END fhir.resources imports ---
|
||||||
|
|
||||||
|
# --- Check for optional 'packaging' library ---
|
||||||
|
try:
|
||||||
|
import packaging.version as pkg_version
|
||||||
|
HAS_PACKAGING_LIB = True
|
||||||
|
logger.info("Optional 'packaging' library found. Using for robust version comparison.")
|
||||||
|
except ImportError:
|
||||||
|
HAS_PACKAGING_LIB = False
|
||||||
|
logger.warning("Optional 'packaging' library not found. Using basic string comparison for versions.")
|
||||||
|
# Define a simple fallback class if packaging is missing
|
||||||
|
class BasicVersion:
|
||||||
|
def __init__(self, v_str): self.v_str = str(v_str)
|
||||||
|
# Define comparison methods for sorting compatibility
|
||||||
|
def __lt__(self, other): return self.v_str < str(other)
|
||||||
|
def __gt__(self, other): return self.v_str > str(other)
|
||||||
|
def __eq__(self, other): return self.v_str == str(other)
|
||||||
|
def __le__(self, other): return self.v_str <= str(other)
|
||||||
|
def __ge__(self, other): return self.v_str >= str(other)
|
||||||
|
def __ne__(self, other): return self.v_str != str(other)
|
||||||
|
def __str__(self): return self.v_str
|
||||||
|
pkg_version = SimpleNamespace(parse=BasicVersion, InvalidVersion=ValueError) # Mock parse and InvalidVersion
|
||||||
|
|
||||||
# --- Constants ---
|
# --- Constants ---
|
||||||
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
|
FHIR_REGISTRY_BASE_URL = "https://packages.fhir.org"
|
||||||
DOWNLOAD_DIR_NAME = "fhir_packages"
|
DOWNLOAD_DIR_NAME = "fhir_packages"
|
||||||
@ -88,6 +116,469 @@ FHIR_R4_BASE_TYPES = {
|
|||||||
"TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription"
|
"TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
#Helper function to support normalize:
|
||||||
|
|
||||||
|
def safe_parse_version(v_str):
|
||||||
|
"""
|
||||||
|
Attempts to parse a version string using packaging.version.
|
||||||
|
Handles common FHIR suffixes like -dev, -ballot, -draft, -preview
|
||||||
|
by treating them as standard pre-releases (-a0, -b0, -rc0) for comparison.
|
||||||
|
Returns a comparable Version object or a fallback for unparseable strings.
|
||||||
|
"""
|
||||||
|
if not v_str or not isinstance(v_str, str):
|
||||||
|
# Handle None or non-string input, treat as lowest possible version
|
||||||
|
return pkg_version.parse("0.0.0a0") # Use alpha pre-release
|
||||||
|
|
||||||
|
# Try standard parsing first
|
||||||
|
try:
|
||||||
|
return pkg_version.parse(v_str)
|
||||||
|
except pkg_version.InvalidVersion:
|
||||||
|
# Handle common FHIR suffixes if standard parsing fails
|
||||||
|
original_v_str = v_str # Keep original for logging
|
||||||
|
v_str_norm = v_str.lower()
|
||||||
|
# Split into base version and suffix
|
||||||
|
base_part = v_str_norm
|
||||||
|
suffix = None
|
||||||
|
if '-' in v_str_norm:
|
||||||
|
parts = v_str_norm.split('-', 1)
|
||||||
|
base_part = parts[0]
|
||||||
|
suffix = parts[1]
|
||||||
|
|
||||||
|
# Check if base looks like a version number
|
||||||
|
if re.match(r'^\d+(\.\d+)*$', base_part):
|
||||||
|
try:
|
||||||
|
# Map FHIR suffixes to PEP 440 pre-release types for sorting
|
||||||
|
if suffix in ['dev', 'snapshot', 'ci-build']:
|
||||||
|
# Treat as alpha (earliest pre-release)
|
||||||
|
return pkg_version.parse(f"{base_part}a0")
|
||||||
|
elif suffix in ['draft', 'ballot', 'preview']:
|
||||||
|
# Treat as beta (after alpha)
|
||||||
|
return pkg_version.parse(f"{base_part}b0")
|
||||||
|
# Add more mappings if needed (e.g., -rc -> rc0)
|
||||||
|
elif suffix and suffix.startswith('rc'):
|
||||||
|
rc_num = ''.join(filter(str.isdigit, suffix)) or '0'
|
||||||
|
return pkg_version.parse(f"{base_part}rc{rc_num}")
|
||||||
|
|
||||||
|
# If suffix isn't recognized, still try parsing base as final/post
|
||||||
|
# This might happen for odd suffixes like -final (though unlikely)
|
||||||
|
# If base itself parses, use that (treats unknown suffix as > pre-release)
|
||||||
|
return pkg_version.parse(base_part)
|
||||||
|
|
||||||
|
except pkg_version.InvalidVersion:
|
||||||
|
# If base_part itself is invalid after splitting
|
||||||
|
logger.warning(f"Invalid base version '{base_part}' after splitting '{original_v_str}'. Treating as alpha.")
|
||||||
|
return pkg_version.parse("0.0.0a0")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error parsing FHIR-suffixed version '{original_v_str}': {e}")
|
||||||
|
return pkg_version.parse("0.0.0a0")
|
||||||
|
else:
|
||||||
|
# Base part doesn't look like numbers/dots (e.g., "current", "dev")
|
||||||
|
logger.warning(f"Unparseable version '{original_v_str}' (base '{base_part}' not standard). Treating as alpha.")
|
||||||
|
return pkg_version.parse("0.0.0a0") # Treat fully non-standard versions as very early
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Catch any other unexpected parsing errors
|
||||||
|
logger.error(f"Unexpected error in safe_parse_version for '{v_str}': {e}")
|
||||||
|
return pkg_version.parse("0.0.0a0") # Fallback
|
||||||
|
|
||||||
|
# --- MODIFIED FUNCTION with Enhanced Logging ---
|
||||||
|
def get_additional_registries():
|
||||||
|
"""Fetches the list of additional FHIR IG registries from the master feed."""
|
||||||
|
logger.debug("Entering get_additional_registries function")
|
||||||
|
feed_registry_url = 'https://raw.githubusercontent.com/FHIR/ig-registry/master/package-feeds.json'
|
||||||
|
feeds = [] # Default to empty list
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to fetch feed registry from {feed_registry_url}")
|
||||||
|
# Use a reasonable timeout
|
||||||
|
response = requests.get(feed_registry_url, timeout=15)
|
||||||
|
logger.debug(f"Feed registry request to {feed_registry_url} returned status code: {response.status_code}")
|
||||||
|
# Raise HTTPError for bad responses (4xx or 5xx)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Log successful fetch
|
||||||
|
logger.debug(f"Successfully fetched feed registry. Response text (first 500 chars): {response.text[:500]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt to parse JSON
|
||||||
|
data = json.loads(response.text)
|
||||||
|
feeds_raw = data.get('feeds', [])
|
||||||
|
# Ensure structure is as expected before adding
|
||||||
|
feeds = [{'name': feed['name'], 'url': feed['url']}
|
||||||
|
for feed in feeds_raw
|
||||||
|
if isinstance(feed, dict) and 'name' in feed and 'url' in feed]
|
||||||
|
logger.info(f"Successfully parsed {len(feeds)} valid feeds from {feed_registry_url}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Log JSON parsing errors specifically
|
||||||
|
logger.error(f"JSON decoding error for feed registry from {feed_registry_url}: {e}")
|
||||||
|
# Log the problematic text snippet to help diagnose
|
||||||
|
logger.error(f"Problematic JSON text snippet: {response.text[:500]}...")
|
||||||
|
# feeds remains []
|
||||||
|
|
||||||
|
# --- Specific Exception Handling ---
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
logger.error(f"HTTP error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
|
||||||
|
# feeds remains []
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
logger.error(f"Connection error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
|
||||||
|
# feeds remains []
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
logger.error(f"Timeout fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
|
||||||
|
# feeds remains []
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
# Catch other potential request-related errors
|
||||||
|
logger.error(f"General request error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
|
||||||
|
# feeds remains []
|
||||||
|
except Exception as e:
|
||||||
|
# Catch any other unexpected errors during the process
|
||||||
|
logger.error(f"Unexpected error fetching feed registry from {feed_registry_url}: {e}", exc_info=True)
|
||||||
|
# feeds remains []
|
||||||
|
|
||||||
|
logger.debug(f"Exiting get_additional_registries function, returning {len(feeds)} feeds.")
|
||||||
|
return feeds
|
||||||
|
# --- END MODIFIED FUNCTION ---
|
||||||
|
|
||||||
|
def fetch_packages_from_registries(search_term=''):
|
||||||
|
logger.debug("Entering fetch_packages_from_registries function with search_term: %s", search_term)
|
||||||
|
packages_dict = defaultdict(list)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("Calling get_additional_registries")
|
||||||
|
feed_registries = get_additional_registries()
|
||||||
|
logger.debug("Returned from get_additional_registries with %d registries: %s", len(feed_registries), feed_registries)
|
||||||
|
|
||||||
|
if not feed_registries:
|
||||||
|
logger.warning("No feed registries available. Cannot fetch packages.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Processing {len(feed_registries)} feed registries")
|
||||||
|
for feed in feed_registries:
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching feed: {feed['name']} from {feed['url']}")
|
||||||
|
response = requests.get(feed['url'], timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Log the raw response content for debugging
|
||||||
|
response_text = response.text[:500] # Limit to first 500 chars for logging
|
||||||
|
logger.debug(f"Raw response from {feed['url']}: {response_text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(response.text)
|
||||||
|
num_feed_packages = len(data.get('packages', []))
|
||||||
|
logger.info(f"Fetched from feed {feed['name']}: {num_feed_packages} packages (JSON)")
|
||||||
|
for pkg in data.get('packages', []):
|
||||||
|
if not isinstance(pkg, dict):
|
||||||
|
continue
|
||||||
|
pkg_name = pkg.get('name', '')
|
||||||
|
if not pkg_name:
|
||||||
|
continue
|
||||||
|
packages_dict[pkg_name].append(pkg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
feed_data = feedparser.parse(response.text)
|
||||||
|
if not feed_data.entries:
|
||||||
|
logger.warning(f"No entries found in feed {feed['name']}")
|
||||||
|
continue
|
||||||
|
num_rss_packages = len(feed_data.entries)
|
||||||
|
logger.info(f"Fetched from feed {feed['name']}: {num_rss_packages} packages (Atom/RSS)")
|
||||||
|
logger.info(f"Sample feed entries from {feed['name']}: {feed_data.entries[:2]}")
|
||||||
|
for entry in feed_data.entries:
|
||||||
|
try:
|
||||||
|
# Extract package name and version from title (e.g., "hl7.fhir.au.ereq#0.3.0-preview")
|
||||||
|
title = entry.get('title', '')
|
||||||
|
if '#' in title:
|
||||||
|
pkg_name, version = title.split('#', 1)
|
||||||
|
else:
|
||||||
|
pkg_name = title
|
||||||
|
version = entry.get('version', '')
|
||||||
|
if not pkg_name:
|
||||||
|
pkg_name = entry.get('id', '') or entry.get('summary', '')
|
||||||
|
if not pkg_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
package = {
|
||||||
|
'name': pkg_name,
|
||||||
|
'version': version,
|
||||||
|
'author': entry.get('author', ''),
|
||||||
|
'fhirVersion': entry.get('fhir_version', [''])[0] or '',
|
||||||
|
'url': entry.get('link', ''),
|
||||||
|
'canonical': entry.get('canonical', ''),
|
||||||
|
'dependencies': entry.get('dependencies', []),
|
||||||
|
'pubDate': entry.get('published', entry.get('pubdate', '')),
|
||||||
|
'registry': feed['url']
|
||||||
|
}
|
||||||
|
if search_term and package['name'] and search_term.lower() not in package['name'].lower():
|
||||||
|
continue
|
||||||
|
packages_dict[pkg_name].append(package)
|
||||||
|
except Exception as entry_error:
|
||||||
|
logger.error(f"Error processing entry in feed {feed['name']}: {entry_error}")
|
||||||
|
logger.info(f"Problematic entry: {entry}")
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
logger.warning(f"Feed endpoint not found for {feed['name']}: {feed['url']} - 404 Not Found")
|
||||||
|
else:
|
||||||
|
logger.error(f"HTTP error fetching from feed {feed['name']}: {e}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error fetching from feed {feed['name']}: {e}")
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Unexpected error fetching from feed {feed['name']}: {error}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in fetch_packages_from_registries: {e}")
|
||||||
|
|
||||||
|
# Convert packages_dict to a list of packages with aggregated versions
|
||||||
|
packages = []
|
||||||
|
for pkg_name, entries in packages_dict.items():
|
||||||
|
# Aggregate versions with their publication dates
|
||||||
|
versions = [
|
||||||
|
{
|
||||||
|
"version": entry.get('version', ''),
|
||||||
|
"pubDate": entry.get('pubDate', '')
|
||||||
|
}
|
||||||
|
for entry in entries
|
||||||
|
if entry.get('version', '')
|
||||||
|
]
|
||||||
|
# Sort versions by pubDate (newest first)
|
||||||
|
versions.sort(key=lambda x: x.get('pubDate', ''), reverse=True)
|
||||||
|
if not versions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Take the latest entry for the main package fields
|
||||||
|
latest_entry = entries[0]
|
||||||
|
package = {
|
||||||
|
'name': pkg_name,
|
||||||
|
'version': latest_entry.get('version', ''),
|
||||||
|
'latestVersion': latest_entry.get('version', ''),
|
||||||
|
'author': latest_entry.get('author', ''),
|
||||||
|
'fhirVersion': latest_entry.get('fhirVersion', ''),
|
||||||
|
'url': latest_entry.get('url', ''),
|
||||||
|
'canonical': latest_entry.get('canonical', ''),
|
||||||
|
'dependencies': latest_entry.get('dependencies', []),
|
||||||
|
'versions': versions, # List of versions with pubDate
|
||||||
|
'registry': latest_entry.get('registry', '')
|
||||||
|
}
|
||||||
|
packages.append(package)
|
||||||
|
|
||||||
|
logger.info(f"Total packages fetched: {len(packages)}")
|
||||||
|
return packages
|
||||||
|
|
||||||
|
def normalize_package_data(raw_packages):
|
||||||
|
"""
|
||||||
|
Normalizes package data, identifying latest absolute and latest official versions.
|
||||||
|
Uses safe_parse_version for robust comparison.
|
||||||
|
"""
|
||||||
|
packages_grouped = defaultdict(list)
|
||||||
|
skipped_raw_count = 0
|
||||||
|
for entry in raw_packages:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
skipped_raw_count += 1
|
||||||
|
logger.warning(f"Skipping raw package entry, not a dict: {entry}")
|
||||||
|
continue
|
||||||
|
raw_name = entry.get('name') or entry.get('title') or ''
|
||||||
|
if not isinstance(raw_name, str):
|
||||||
|
raw_name = str(raw_name)
|
||||||
|
name_part = raw_name.split('#', 1)[0].strip().lower()
|
||||||
|
if name_part:
|
||||||
|
packages_grouped[name_part].append(entry)
|
||||||
|
else:
|
||||||
|
if not entry.get('id'):
|
||||||
|
skipped_raw_count += 1
|
||||||
|
logger.warning(f"Skipping raw package entry, no name or id: {entry}")
|
||||||
|
logger.info(f"Initial grouping: {len(packages_grouped)} unique package names found. Skipped {skipped_raw_count} raw entries.")
|
||||||
|
|
||||||
|
normalized_list = []
|
||||||
|
skipped_norm_count = 0
|
||||||
|
total_entries_considered = 0
|
||||||
|
|
||||||
|
for name_key, entries in packages_grouped.items():
|
||||||
|
total_entries_considered += len(entries)
|
||||||
|
latest_absolute_data = None
|
||||||
|
latest_official_data = None
|
||||||
|
latest_absolute_ver_for_comp = safe_parse_version("0.0.0a0")
|
||||||
|
latest_official_ver_for_comp = safe_parse_version("0.0.0a0")
|
||||||
|
all_versions = []
|
||||||
|
package_name_display = name_key
|
||||||
|
|
||||||
|
# Aggregate all versions from entries
|
||||||
|
processed_versions = set()
|
||||||
|
for package_entry in entries:
|
||||||
|
versions_list = package_entry.get('versions', [])
|
||||||
|
for version_info in versions_list:
|
||||||
|
if isinstance(version_info, dict) and 'version' in version_info:
|
||||||
|
version_str = version_info.get('version', '')
|
||||||
|
if version_str and version_str not in processed_versions:
|
||||||
|
all_versions.append(version_info)
|
||||||
|
processed_versions.add(version_str)
|
||||||
|
|
||||||
|
processed_entries = []
|
||||||
|
for package_entry in entries:
|
||||||
|
version_str = None
|
||||||
|
raw_name_entry = package_entry.get('name') or package_entry.get('title') or ''
|
||||||
|
if not isinstance(raw_name_entry, str):
|
||||||
|
raw_name_entry = str(raw_name_entry)
|
||||||
|
version_keys = ['version', 'latestVersion']
|
||||||
|
for key in version_keys:
|
||||||
|
val = package_entry.get(key)
|
||||||
|
if isinstance(val, str) and val:
|
||||||
|
version_str = val.strip()
|
||||||
|
break
|
||||||
|
elif isinstance(val, list) and val and isinstance(val[0], str) and val[0]:
|
||||||
|
version_str = val[0].strip()
|
||||||
|
break
|
||||||
|
if not version_str and '#' in raw_name_entry:
|
||||||
|
parts = raw_name_entry.split('#', 1)
|
||||||
|
if len(parts) == 2 and parts[1]:
|
||||||
|
version_str = parts[1].strip()
|
||||||
|
|
||||||
|
if not version_str:
|
||||||
|
logger.warning(f"Skipping entry for {raw_name_entry}: no valid version found. Entry: {package_entry}")
|
||||||
|
skipped_norm_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
version_str = version_str.strip()
|
||||||
|
current_display_name = str(raw_name_entry).split('#')[0].strip()
|
||||||
|
if current_display_name and current_display_name != name_key:
|
||||||
|
package_name_display = current_display_name
|
||||||
|
|
||||||
|
entry_with_version = package_entry.copy()
|
||||||
|
entry_with_version['version'] = version_str
|
||||||
|
processed_entries.append(entry_with_version)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_ver_obj_for_comp = safe_parse_version(version_str)
|
||||||
|
if latest_absolute_data is None or current_ver_obj_for_comp > latest_absolute_ver_for_comp:
|
||||||
|
latest_absolute_ver_for_comp = current_ver_obj_for_comp
|
||||||
|
latest_absolute_data = entry_with_version
|
||||||
|
|
||||||
|
if re.match(r'^\d+\.\d+\.\d+(?:-[a-zA-Z0-9\.]+)?$', version_str):
|
||||||
|
if latest_official_data is None or current_ver_obj_for_comp > latest_official_ver_for_comp:
|
||||||
|
latest_official_ver_for_comp = current_ver_obj_for_comp
|
||||||
|
latest_official_data = entry_with_version
|
||||||
|
except Exception as comp_err:
|
||||||
|
logger.error(f"Error comparing version '{version_str}' for package '{package_name_display}': {comp_err}", exc_info=True)
|
||||||
|
|
||||||
|
if latest_absolute_data:
|
||||||
|
final_absolute_version = latest_absolute_data.get('version', 'unknown')
|
||||||
|
final_official_version = latest_official_data.get('version') if latest_official_data else None
|
||||||
|
|
||||||
|
author_raw = latest_absolute_data.get('author') or latest_absolute_data.get('publisher') or ''
|
||||||
|
if isinstance(author_raw, dict):
|
||||||
|
author = author_raw.get('name', str(author_raw))
|
||||||
|
elif not isinstance(author_raw, str):
|
||||||
|
author = str(author_raw)
|
||||||
|
else:
|
||||||
|
author = author_raw
|
||||||
|
|
||||||
|
fhir_version_str = None
|
||||||
|
fhir_keys = ['fhirVersion', 'fhirVersions', 'fhir_version']
|
||||||
|
for key in fhir_keys:
|
||||||
|
val = latest_absolute_data.get(key)
|
||||||
|
if isinstance(val, list) and val and isinstance(val[0], str):
|
||||||
|
fhir_version_str = val[0]
|
||||||
|
break
|
||||||
|
elif isinstance(val, str) and val:
|
||||||
|
fhir_version_str = val
|
||||||
|
break
|
||||||
|
fhir_version_str = fhir_version_str or 'unknown'
|
||||||
|
|
||||||
|
url_raw = latest_absolute_data.get('url') or latest_absolute_data.get('link') or ''
|
||||||
|
url = str(url_raw) if not isinstance(url_raw, str) else url_raw
|
||||||
|
canonical_raw = latest_absolute_data.get('canonical') or url
|
||||||
|
canonical = str(canonical_raw) if not isinstance(canonical_raw, str) else canonical_raw
|
||||||
|
|
||||||
|
dependencies_raw = latest_absolute_data.get('dependencies', [])
|
||||||
|
dependencies = []
|
||||||
|
if isinstance(dependencies_raw, dict):
|
||||||
|
dependencies = [{"name": str(dn), "version": str(dv)} for dn, dv in dependencies_raw.items()]
|
||||||
|
elif isinstance(dependencies_raw, list):
|
||||||
|
for dep in dependencies_raw:
|
||||||
|
if isinstance(dep, str):
|
||||||
|
if '@' in dep:
|
||||||
|
dep_name, dep_version = dep.split('@', 1)
|
||||||
|
dependencies.append({"name": dep_name, "version": dep_version})
|
||||||
|
else:
|
||||||
|
dependencies.append({"name": dep, "version": "N/A"})
|
||||||
|
elif isinstance(dep, dict) and 'name' in dep and 'version' in dep:
|
||||||
|
dependencies.append(dep)
|
||||||
|
|
||||||
|
# Sort all_versions by pubDate (newest first)
|
||||||
|
all_versions.sort(key=lambda x: x.get('pubDate', ''), reverse=True)
|
||||||
|
|
||||||
|
normalized_entry = {
|
||||||
|
'name': package_name_display,
|
||||||
|
'version': final_absolute_version,
|
||||||
|
'latest_absolute_version': final_absolute_version,
|
||||||
|
'latest_official_version': final_official_version,
|
||||||
|
'author': author.strip(),
|
||||||
|
'fhir_version': fhir_version_str.strip(),
|
||||||
|
'url': url.strip(),
|
||||||
|
'canonical': canonical.strip(),
|
||||||
|
'dependencies': dependencies,
|
||||||
|
'version_count': len(all_versions),
|
||||||
|
'all_versions': all_versions, # Preserve the full versions list with pubDate
|
||||||
|
'versions_data': processed_entries,
|
||||||
|
'registry': latest_absolute_data.get('registry', '')
|
||||||
|
}
|
||||||
|
normalized_list.append(normalized_entry)
|
||||||
|
if not final_official_version:
|
||||||
|
logger.warning(f"No official version found for package '{package_name_display}'. Versions: {[v['version'] for v in all_versions]}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"No valid entries found to determine details for package name key '{name_key}'. Entries: {entries}")
|
||||||
|
skipped_norm_count += len(entries)
|
||||||
|
|
||||||
|
logger.info(f"Normalization complete. Entries considered: {total_entries_considered}, Skipped during norm: {skipped_norm_count}, Unique Packages Found: {len(normalized_list)}")
|
||||||
|
normalized_list.sort(key=lambda x: x.get('name', '').lower())
|
||||||
|
return normalized_list
|
||||||
|
|
||||||
|
def cache_packages(normalized_packages, db, CachedPackage):
|
||||||
|
"""
|
||||||
|
Cache normalized FHIR Implementation Guide packages in the CachedPackage database.
|
||||||
|
Updates existing records or adds new ones to improve performance for other routes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
normalized_packages (list): List of normalized package dictionaries.
|
||||||
|
db: The SQLAlchemy database instance.
|
||||||
|
CachedPackage: The CachedPackage model class.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for package in normalized_packages:
|
||||||
|
existing = CachedPackage.query.filter_by(package_name=package['name'], version=package['version']).first()
|
||||||
|
if existing:
|
||||||
|
existing.author = package['author']
|
||||||
|
existing.fhir_version = package['fhir_version']
|
||||||
|
existing.version_count = package['version_count']
|
||||||
|
existing.url = package['url']
|
||||||
|
existing.all_versions = package['all_versions']
|
||||||
|
existing.dependencies = package['dependencies']
|
||||||
|
existing.latest_absolute_version = package['latest_absolute_version']
|
||||||
|
existing.latest_official_version = package['latest_official_version']
|
||||||
|
existing.canonical = package['canonical']
|
||||||
|
existing.registry = package.get('registry', '')
|
||||||
|
else:
|
||||||
|
new_package = CachedPackage(
|
||||||
|
package_name=package['name'],
|
||||||
|
version=package['version'],
|
||||||
|
author=package['author'],
|
||||||
|
fhir_version=package['fhir_version'],
|
||||||
|
version_count=package['version_count'],
|
||||||
|
url=package['url'],
|
||||||
|
all_versions=package['all_versions'],
|
||||||
|
dependencies=package['dependencies'],
|
||||||
|
latest_absolute_version=package['latest_absolute_version'],
|
||||||
|
latest_official_version=package['latest_official_version'],
|
||||||
|
canonical=package['canonical'],
|
||||||
|
registry=package.get('registry', '')
|
||||||
|
)
|
||||||
|
db.session.add(new_package)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"Cached {len(normalized_packages)} packages in CachedPackage.")
|
||||||
|
except Exception as error:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error caching packages: {error}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
#-----------------------------------------------------------------------
|
||||||
|
|
||||||
# --- Helper Functions ---
|
# --- Helper Functions ---
|
||||||
|
|
||||||
def _get_download_dir():
|
def _get_download_dir():
|
||||||
@ -121,6 +612,28 @@ def _get_download_dir():
|
|||||||
logger.error(f"Unexpected error getting/creating packages directory {packages_dir}: {e}", exc_info=True)
|
logger.error(f"Unexpected error getting/creating packages directory {packages_dir}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# --- Helper to get description (Add this to services.py) ---
|
||||||
|
def get_package_description(package_name, package_version, packages_dir):
|
||||||
|
"""Reads package.json from a tgz and returns the description."""
|
||||||
|
tgz_filename = construct_tgz_filename(package_name, package_version)
|
||||||
|
if not tgz_filename: return "Error: Could not construct filename."
|
||||||
|
tgz_path = os.path.join(packages_dir, tgz_filename)
|
||||||
|
if not os.path.exists(tgz_path):
|
||||||
|
return f"Error: Package file not found ({tgz_filename})."
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||||
|
pkg_json_member = next((m for m in tar if m.name == 'package/package.json'), None)
|
||||||
|
if pkg_json_member:
|
||||||
|
with tar.extractfile(pkg_json_member) as f:
|
||||||
|
pkg_data = json.load(f)
|
||||||
|
return pkg_data.get('description', 'No description found in package.json.')
|
||||||
|
else:
|
||||||
|
return "Error: package.json not found in archive."
|
||||||
|
except (tarfile.TarError, json.JSONDecodeError, KeyError, IOError, Exception) as e:
|
||||||
|
logger.error(f"Error reading description from {tgz_filename}: {e}")
|
||||||
|
return f"Error reading package details: {e}"
|
||||||
|
|
||||||
def sanitize_filename_part(text):
|
def sanitize_filename_part(text):
|
||||||
"""Basic sanitization for name/version parts of filename."""
|
"""Basic sanitization for name/version parts of filename."""
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
@ -2785,7 +3298,8 @@ def process_and_upload_test_data(server_info, options, temp_file_dir):
|
|||||||
raise ValueError("XML root tag missing.")
|
raise ValueError("XML root tag missing.")
|
||||||
temp_dict = basic_fhir_xml_to_dict(content)
|
temp_dict = basic_fhir_xml_to_dict(content)
|
||||||
if temp_dict:
|
if temp_dict:
|
||||||
fhir_resource = construct(resource_type, temp_dict)
|
model_class = get_fhir_model_class(resource_type)
|
||||||
|
fhir_resource = model_class(**temp_dict)
|
||||||
resource_dict = fhir_resource.dict(exclude_none=True)
|
resource_dict = fhir_resource.dict(exclude_none=True)
|
||||||
if 'id' in resource_dict:
|
if 'id' in resource_dict:
|
||||||
parsed_content_list.append(resource_dict)
|
parsed_content_list.append(resource_dict)
|
||||||
@ -3228,6 +3742,367 @@ def process_and_upload_test_data(server_info, options, temp_file_dir):
|
|||||||
|
|
||||||
# --- END Service Function ---
|
# --- END Service Function ---
|
||||||
|
|
||||||
|
# --- CORRECTED retrieve_bundles function with NEW logic ---
|
||||||
|
def retrieve_bundles(fhir_server_url, resources, output_zip, validate_references=False, fetch_reference_bundles=False):
|
||||||
|
"""
|
||||||
|
Retrieve FHIR bundles and save to a ZIP file.
|
||||||
|
Optionally fetches referenced resources, either individually by ID or as full bundles by type.
|
||||||
|
Yields NDJSON progress updates.
|
||||||
|
"""
|
||||||
|
temp_dir = None
|
||||||
|
try:
|
||||||
|
total_initial_bundles = 0
|
||||||
|
fetched_individual_references = 0
|
||||||
|
fetched_type_bundles = 0
|
||||||
|
retrieved_references_or_types = set() # Track fetched items to avoid duplicates
|
||||||
|
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="fhir_retrieve_")
|
||||||
|
logger.debug(f"Created temporary directory for bundle retrieval: {temp_dir}")
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Starting bundle retrieval for {len(resources)} resource types"}) + "\n"
|
||||||
|
if validate_references:
|
||||||
|
yield json.dumps({"type": "info", "message": f"Reference fetching ON (Mode: {'Full Type Bundles' if fetch_reference_bundles else 'Individual Resources'})"}) + "\n"
|
||||||
|
else:
|
||||||
|
yield json.dumps({"type": "info", "message": "Reference fetching OFF"}) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Determine Base URL and Headers for Proxy ---
|
||||||
|
base_proxy_url = 'http://localhost:5000/fhir' # Always target the proxy endpoint
|
||||||
|
headers = {'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8'}
|
||||||
|
is_custom_url = fhir_server_url != '/fhir' and fhir_server_url is not None and fhir_server_url.startswith('http')
|
||||||
|
if is_custom_url:
|
||||||
|
headers['X-Target-FHIR-Server'] = fhir_server_url.rstrip('/')
|
||||||
|
logger.debug(f"Will use proxy with X-Target-FHIR-Server: {headers['X-Target-FHIR-Server']}")
|
||||||
|
else:
|
||||||
|
logger.debug("Will use proxy targeting local HAPI server")
|
||||||
|
|
||||||
|
# --- Fetch Initial Bundles ---
|
||||||
|
initial_bundle_files = [] # Store paths for reference scanning
|
||||||
|
for resource_type in resources:
|
||||||
|
url = f"{base_proxy_url}/{quote(resource_type)}"
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Fetching bundle for {resource_type} via proxy..."}) + "\n"
|
||||||
|
logger.debug(f"Sending GET request to proxy {url} with headers: {json.dumps(headers)}")
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=60)
|
||||||
|
logger.debug(f"Proxy response for {resource_type}: HTTP {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
# ... (keep existing error handling for initial fetch) ...
|
||||||
|
error_detail = f"Proxy returned HTTP {response.status_code}."
|
||||||
|
try: error_detail += f" Body: {response.text[:200]}..."
|
||||||
|
except: pass
|
||||||
|
yield json.dumps({"type": "error", "message": f"Failed to fetch {resource_type}: {error_detail}"}) + "\n"
|
||||||
|
logger.error(f"Failed to fetch {resource_type} via proxy {url}: {error_detail}")
|
||||||
|
continue
|
||||||
|
try: bundle = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Invalid JSON response for {resource_type}: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Invalid JSON from proxy for {resource_type} at {url}: {e}, Response: {response.text[:500]}")
|
||||||
|
continue
|
||||||
|
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
|
||||||
|
yield json.dumps({"type": "error", "message": f"Expected Bundle for {resource_type}, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
|
||||||
|
logger.error(f"Expected Bundle for {resource_type}, got {bundle.get('resourceType', 'unknown')}")
|
||||||
|
continue
|
||||||
|
if not bundle.get('entry'):
|
||||||
|
yield json.dumps({"type": "warning", "message": f"No entries found in bundle for {resource_type}"}) + "\n"
|
||||||
|
|
||||||
|
# Save the bundle
|
||||||
|
output_file = os.path.join(temp_dir, f"{resource_type}_bundle.json")
|
||||||
|
try:
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
|
||||||
|
logger.debug(f"Wrote bundle to {output_file}")
|
||||||
|
initial_bundle_files.append(output_file) # Add for scanning
|
||||||
|
total_initial_bundles += 1
|
||||||
|
yield json.dumps({"type": "success", "message": f"Saved bundle for {resource_type}"}) + "\n"
|
||||||
|
except IOError as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Failed to save bundle file for {resource_type}: {e}"}) + "\n"
|
||||||
|
logger.error(f"Failed to write bundle file {output_file}: {e}")
|
||||||
|
continue
|
||||||
|
except requests.RequestException as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Error connecting to proxy for {resource_type}: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Error retrieving bundle for {resource_type} via proxy {url}: {e}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Unexpected error fetching {resource_type}: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Unexpected error during initial fetch for {resource_type} at {url}: {e}", exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Fetch Referenced Resources (Conditionally) ---
|
||||||
|
if validate_references and initial_bundle_files:
|
||||||
|
yield json.dumps({"type": "progress", "message": "Scanning retrieved bundles for references..."}) + "\n"
|
||||||
|
all_references = set()
|
||||||
|
references_by_type = defaultdict(set) # To store { 'Patient': {'id1', 'id2'}, ... }
|
||||||
|
|
||||||
|
# --- Scan for References ---
|
||||||
|
for bundle_file_path in initial_bundle_files:
|
||||||
|
try:
|
||||||
|
with open(bundle_file_path, 'r', encoding='utf-8') as f: bundle = json.load(f)
|
||||||
|
for entry in bundle.get('entry', []):
|
||||||
|
resource = entry.get('resource')
|
||||||
|
if resource:
|
||||||
|
current_refs = []
|
||||||
|
find_references(resource, current_refs)
|
||||||
|
for ref_str in current_refs:
|
||||||
|
if isinstance(ref_str, str) and '/' in ref_str and not ref_str.startswith('#'):
|
||||||
|
all_references.add(ref_str)
|
||||||
|
# Group by type for bundle fetch mode
|
||||||
|
try:
|
||||||
|
ref_type = ref_str.split('/')[0]
|
||||||
|
if ref_type: references_by_type[ref_type].add(ref_str)
|
||||||
|
except Exception: pass # Ignore parsing errors here
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Could not scan references in {os.path.basename(bundle_file_path)}: {e}"}) + "\n"
|
||||||
|
logger.warning(f"Error processing references in {bundle_file_path}: {e}")
|
||||||
|
|
||||||
|
# --- Fetch Logic ---
|
||||||
|
if not all_references:
|
||||||
|
yield json.dumps({"type": "info", "message": "No references found to fetch."}) + "\n"
|
||||||
|
else:
|
||||||
|
if fetch_reference_bundles:
|
||||||
|
# --- Fetch Full Bundles by Type ---
|
||||||
|
unique_ref_types = sorted(list(references_by_type.keys()))
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Fetching full bundles for {len(unique_ref_types)} referenced types..."}) + "\n"
|
||||||
|
logger.info(f"Fetching full bundles for referenced types: {unique_ref_types}")
|
||||||
|
|
||||||
|
for ref_type in unique_ref_types:
|
||||||
|
if ref_type in retrieved_references_or_types: continue # Skip if type bundle already fetched
|
||||||
|
|
||||||
|
url = f"{base_proxy_url}/{quote(ref_type)}" # Fetch all of this type
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Fetching full bundle for type {ref_type} via proxy..."}) + "\n"
|
||||||
|
logger.debug(f"Sending GET request for full type bundle {ref_type} to proxy {url} with headers: {json.dumps(headers)}")
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=180) # Longer timeout for full bundles
|
||||||
|
logger.debug(f"Proxy response for {ref_type} bundle: HTTP {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_detail = f"Proxy returned HTTP {response.status_code}."
|
||||||
|
try: error_detail += f" Body: {response.text[:200]}..."
|
||||||
|
except: pass
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Failed to fetch full bundle for {ref_type}: {error_detail}"}) + "\n"
|
||||||
|
logger.warning(f"Failed to fetch full bundle {ref_type} via proxy {url}: {error_detail}")
|
||||||
|
retrieved_references_or_types.add(ref_type) # Mark type as attempted
|
||||||
|
continue
|
||||||
|
|
||||||
|
try: bundle = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Invalid JSON for full {ref_type} bundle: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Invalid JSON response from proxy for full {ref_type} bundle at {url}: {e}")
|
||||||
|
retrieved_references_or_types.add(ref_type)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Expected Bundle for full {ref_type} fetch, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
|
||||||
|
logger.warning(f"Expected Bundle for full {ref_type} fetch, got {bundle.get('resourceType', 'unknown')}")
|
||||||
|
retrieved_references_or_types.add(ref_type)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save the full type bundle
|
||||||
|
output_file = os.path.join(temp_dir, f"ref_{ref_type}_BUNDLE.json")
|
||||||
|
try:
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
|
||||||
|
logger.debug(f"Wrote full type bundle to {output_file}")
|
||||||
|
fetched_type_bundles += 1
|
||||||
|
retrieved_references_or_types.add(ref_type)
|
||||||
|
yield json.dumps({"type": "success", "message": f"Saved full bundle for type {ref_type}"}) + "\n"
|
||||||
|
except IOError as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Failed to save full bundle file for {ref_type}: {e}"}) + "\n"
|
||||||
|
logger.error(f"Failed to write full bundle file {output_file}: {e}")
|
||||||
|
retrieved_references_or_types.add(ref_type) # Still mark as attempted
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Error connecting to proxy for full {ref_type} bundle: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Error retrieving full {ref_type} bundle via proxy: {e}")
|
||||||
|
retrieved_references_or_types.add(ref_type)
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Unexpected error fetching full {ref_type} bundle: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Unexpected error during full {ref_type} bundle fetch: {e}", exc_info=True)
|
||||||
|
retrieved_references_or_types.add(ref_type)
|
||||||
|
# End loop through ref_types
|
||||||
|
else:
|
||||||
|
# --- Fetch Individual Referenced Resources ---
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Fetching {len(all_references)} unique referenced resources individually..."}) + "\n"
|
||||||
|
logger.info(f"Fetching {len(all_references)} unique referenced resources by ID.")
|
||||||
|
for ref in sorted(list(all_references)): # Sort for consistent order
|
||||||
|
if ref in retrieved_references_or_types: continue # Skip already fetched
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse reference
|
||||||
|
ref_parts = ref.split('/')
|
||||||
|
if len(ref_parts) != 2 or not ref_parts[0] or not ref_parts[1]:
|
||||||
|
logger.warning(f"Skipping invalid reference format: {ref}")
|
||||||
|
continue
|
||||||
|
ref_type, ref_id = ref_parts
|
||||||
|
|
||||||
|
# Fetch individual resource using _id search
|
||||||
|
search_param = quote(f"_id={ref_id}")
|
||||||
|
url = f"{base_proxy_url}/{quote(ref_type)}?{search_param}"
|
||||||
|
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Fetching referenced {ref_type}/{ref_id} via proxy..."}) + "\n"
|
||||||
|
logger.debug(f"Sending GET request for referenced {ref} to proxy {url} with headers: {json.dumps(headers)}")
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=60)
|
||||||
|
logger.debug(f"Proxy response for referenced {ref}: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_detail = f"Proxy returned HTTP {response.status_code}."
|
||||||
|
try: error_detail += f" Body: {response.text[:200]}..."
|
||||||
|
except: pass
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Failed to fetch referenced {ref}: {error_detail}"}) + "\n"
|
||||||
|
logger.warning(f"Failed to fetch referenced {ref} via proxy {url}: {error_detail}")
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try: bundle = response.json()
|
||||||
|
except ValueError as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Invalid JSON for referenced {ref}: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Invalid JSON from proxy for ref {ref} at {url}: {e}")
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(bundle, dict) or bundle.get('resourceType') != 'Bundle':
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Expected Bundle for referenced {ref}, got {bundle.get('resourceType', 'unknown')}"}) + "\n"
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bundle.get('entry'):
|
||||||
|
yield json.dumps({"type": "info", "message": f"Referenced resource {ref} not found on server." }) + "\n"
|
||||||
|
logger.info(f"Referenced resource {ref} not found via search {url}")
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save the bundle containing the single referenced resource
|
||||||
|
# Use a filename indicating it's an individual reference fetch
|
||||||
|
output_file = os.path.join(temp_dir, f"ref_{ref_type}_{ref_id}.json")
|
||||||
|
try:
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f: json.dump(bundle, f, indent=2)
|
||||||
|
logger.debug(f"Wrote referenced resource bundle to {output_file}")
|
||||||
|
fetched_individual_references += 1
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
yield json.dumps({"type": "success", "message": f"Saved referenced resource {ref}"}) + "\n"
|
||||||
|
except IOError as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Failed to save file for referenced {ref}: {e}"}) + "\n"
|
||||||
|
logger.error(f"Failed to write file {output_file}: {e}")
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Network error fetching referenced {ref}: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Network error retrieving referenced {ref} via proxy: {e}")
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "warning", "message": f"Unexpected error fetching referenced {ref}: {str(e)}"}) + "\n"
|
||||||
|
logger.warning(f"Unexpected error during reference fetch for {ref}: {e}", exc_info=True)
|
||||||
|
retrieved_references_or_types.add(ref)
|
||||||
|
# End loop through individual references
|
||||||
|
# --- End Reference Fetching Logic ---
|
||||||
|
|
||||||
|
# --- Create Final ZIP File ---
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Creating ZIP file {os.path.basename(output_zip)}..."}) + "\n"
|
||||||
|
files_to_zip = [f for f in os.listdir(temp_dir) if f.endswith('.json')]
|
||||||
|
if not files_to_zip:
|
||||||
|
yield json.dumps({"type": "warning", "message": "No bundle files were successfully retrieved to include in ZIP."}) + "\n"
|
||||||
|
logger.warning(f"No JSON files found in {temp_dir} to include in ZIP.")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Found {len(files_to_zip)} JSON files to include in ZIP: {files_to_zip}")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for filename in files_to_zip:
|
||||||
|
file_path = os.path.join(temp_dir, filename)
|
||||||
|
if os.path.exists(file_path): zipf.write(file_path, filename)
|
||||||
|
else: logger.error(f"File {file_path} disappeared before adding to ZIP.")
|
||||||
|
yield json.dumps({"type": "success", "message": f"ZIP file created: {os.path.basename(output_zip)} with {len(files_to_zip)} files."}) + "\n"
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Failed to create ZIP file: {e}"}) + "\n"
|
||||||
|
logger.error(f"Error creating ZIP file {output_zip}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# --- Final Completion Message ---
|
||||||
|
completion_message = (
|
||||||
|
f"Bundle retrieval finished. Initial bundles: {total_initial_bundles}, "
|
||||||
|
f"Referenced items fetched: {fetched_individual_references if not fetch_reference_bundles else fetched_type_bundles} "
|
||||||
|
f"({ 'individual resources' if not fetch_reference_bundles else 'full type bundles' })."
|
||||||
|
)
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "complete",
|
||||||
|
"message": completion_message,
|
||||||
|
"data": {
|
||||||
|
"total_initial_bundles": total_initial_bundles,
|
||||||
|
"fetched_individual_references": fetched_individual_references,
|
||||||
|
"fetched_type_bundles": fetched_type_bundles,
|
||||||
|
"reference_mode": "individual" if validate_references and not fetch_reference_bundles else "type_bundle" if validate_references and fetch_reference_bundles else "off"
|
||||||
|
}
|
||||||
|
}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Catch errors during setup (like temp dir creation)
|
||||||
|
yield json.dumps({"type": "error", "message": f"Critical error during retrieval setup: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Unexpected error in retrieve_bundles setup: {e}", exc_info=True)
|
||||||
|
yield json.dumps({ "type": "complete", "message": f"Retrieval failed: {str(e)}", "data": {"total_initial_bundles": 0, "fetched_individual_references": 0, "fetched_type_bundles": 0} }) + "\n"
|
||||||
|
finally:
|
||||||
|
# --- Cleanup Temporary Directory ---
|
||||||
|
if temp_dir and os.path.exists(temp_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
logger.debug(f"Successfully removed temporary directory: {temp_dir}")
|
||||||
|
except Exception as cleanup_e:
|
||||||
|
logger.error(f"Error removing temporary directory {temp_dir}: {cleanup_e}", exc_info=True)
|
||||||
|
# --- End corrected retrieve_bundles function ---
|
||||||
|
|
||||||
|
def split_bundles(input_zip_path, output_zip):
|
||||||
|
"""Split FHIR bundles from a ZIP file into individual resource JSON files and save to a ZIP."""
|
||||||
|
try:
|
||||||
|
total_resources = 0
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Starting bundle splitting from ZIP"}) + "\n"
|
||||||
|
|
||||||
|
# Extract input ZIP
|
||||||
|
with zipfile.ZipFile(input_zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Extracted input ZIP to temporary directory"}) + "\n"
|
||||||
|
|
||||||
|
# Process JSON files
|
||||||
|
for filename in os.listdir(temp_dir):
|
||||||
|
if not filename.endswith('.json'):
|
||||||
|
continue
|
||||||
|
input_file = os.path.join(temp_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(input_file, 'r', encoding='utf-8') as f:
|
||||||
|
bundle = json.load(f)
|
||||||
|
if bundle.get('resourceType') != 'Bundle':
|
||||||
|
yield json.dumps({"type": "error", "message": f"Skipping {filename}: Not a Bundle"}) + "\n"
|
||||||
|
continue
|
||||||
|
yield json.dumps({"type": "progress", "message": f"Processing bundle {filename}"}) + "\n"
|
||||||
|
index = 1
|
||||||
|
for entry in bundle.get('entry', []):
|
||||||
|
resource = entry.get('resource')
|
||||||
|
if not resource or not resource.get('resourceType'):
|
||||||
|
yield json.dumps({"type": "error", "message": f"Invalid resource in {filename} at entry {index}"}) + "\n"
|
||||||
|
continue
|
||||||
|
resource_type = resource['resourceType']
|
||||||
|
output_file = os.path.join(temp_dir, f"{resource_type}-{index}.json")
|
||||||
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(resource, f, indent=2)
|
||||||
|
total_resources += 1
|
||||||
|
yield json.dumps({"type": "success", "message": f"Saved {resource_type}-{index}.json"}) + "\n"
|
||||||
|
index += 1
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Error processing {filename}: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Error splitting bundle {filename}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Create output ZIP
|
||||||
|
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for filename in os.listdir(temp_dir):
|
||||||
|
if filename.endswith('.json') and '-' in filename:
|
||||||
|
zipf.write(os.path.join(temp_dir, filename), filename)
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "complete",
|
||||||
|
"message": f"Bundle splitting completed. Extracted {total_resources} resources.",
|
||||||
|
"data": {"total_resources": total_resources}
|
||||||
|
}) + "\n"
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"type": "error", "message": f"Unexpected error during splitting: {str(e)}"}) + "\n"
|
||||||
|
logger.error(f"Unexpected error in split_bundles: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_dir):
|
||||||
|
for filename in os.listdir(temp_dir):
|
||||||
|
os.remove(os.path.join(temp_dir, filename))
|
||||||
|
os.rmdir(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
# --- Standalone Test ---
|
# --- Standalone Test ---
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
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 {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply overflow and height constraints only for fire animation page */
|
||||||
|
body.fire-animation-page {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fire animation overlay */
|
/* Fire animation overlay */
|
||||||
|
@ -1,225 +1,223 @@
|
|||||||
/* Scoped to .fire-loading-overlay to prevent conflicts */
|
/* /app/static/css/fire-animation.css */
|
||||||
.fire-loading-overlay {
|
|
||||||
position: fixed;
|
/* Removed html, body, stage styles */
|
||||||
top: 0;
|
|
||||||
left: 0;
|
.minifire-container { /* Add a wrapper for positioning/sizing */
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(39, 5, 55, 0.8); /* Semi-transparent gradient background */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1050; /* Above Bootstrap modals */
|
|
||||||
}
|
|
||||||
|
|
||||||
.fire-loading-overlay .campfire {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 400px; /* Reduced size for better fit */
|
height: 130px; /* Overall height of the animation area */
|
||||||
height: 400px;
|
overflow: hidden; /* Hide parts extending beyond the container */
|
||||||
transform-origin: center center;
|
background-color: #270537; /* Optional: Add a background */
|
||||||
transform: scale(0.6); /* Scaled down for responsiveness */
|
border-radius: 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
margin-top: 0.5rem; /* Space above animation */
|
||||||
}
|
}
|
||||||
|
|
||||||
.fire-loading-overlay .log {
|
.minifire-campfire {
|
||||||
|
position: relative;
|
||||||
|
/* Base size significantly reduced (original was 600px) */
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
/* Scale down slightly more if needed, adjusted positioning based on origin */
|
||||||
|
transform: scale(0.8) translateY(15px); /* Pushes it down slightly */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scaled Down Logs --- */
|
||||||
|
.minifire-log {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 238px;
|
width: 60px; /* 238/4 */
|
||||||
height: 70px;
|
height: 18px; /* 70/4 */
|
||||||
border-radius: 32px;
|
border-radius: 8px; /* 32/4 */
|
||||||
background: #781e20;
|
background: #781e20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0.99;
|
opacity: 0.99;
|
||||||
|
transform-origin: center center;
|
||||||
|
box-shadow: 0 0 1px 0.5px rgba(0,0,0,0.15); /* Scaled shadow */
|
||||||
}
|
}
|
||||||
|
|
||||||
.fire-loading-overlay .log:before {
|
.minifire-log:before {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 35px;
|
left: 9px; /* 35/4 */
|
||||||
width: 8px;
|
width: 2px; /* 8/4 */
|
||||||
height: 8px;
|
height: 2px; /* 8/4 */
|
||||||
border-radius: 32px;
|
border-radius: 8px; /* 32/4 */
|
||||||
background: #b35050;
|
background: #b35050;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-shadow: 0 0 0 2.5px #781e20, 0 0 0 10.5px #b35050, 0 0 0 13px #781e20, 0 0 0 21px #b35050, 0 0 0 23.5px #781e20, 0 0 0 31.5px #b35050;
|
/* Scaled box-shadows */
|
||||||
|
box-shadow: 0 0 0 0.5px #781e20, /* 2.5/4 -> 0.6 -> 0.5 */
|
||||||
|
0 0 0 2.5px #b35050, /* 10.5/4 -> 2.6 -> 2.5 */
|
||||||
|
0 0 0 3.5px #781e20, /* 13/4 -> 3.25 -> 3.5 */
|
||||||
|
0 0 0 5.5px #b35050, /* 21/4 -> 5.25 -> 5.5 */
|
||||||
|
0 0 0 6px #781e20, /* 23.5/4 -> 5.9 -> 6 */
|
||||||
|
0 0 0 8px #b35050; /* 31.5/4 -> 7.9 -> 8 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.fire-loading-overlay .streak {
|
.minifire-streak {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 2px;
|
height: 1px; /* Min height */
|
||||||
border-radius: 20px;
|
border-radius: 5px; /* 20/4 */
|
||||||
background: #b35050;
|
background: #b35050;
|
||||||
}
|
}
|
||||||
|
/* Scaled streaks */
|
||||||
|
.minifire-streak:nth-child(1) { top: 3px; width: 23px; } /* 10/4, 90/4 */
|
||||||
|
.minifire-streak:nth-child(2) { top: 3px; left: 25px; width: 20px; } /* 10/4, 100/4, 80/4 */
|
||||||
|
.minifire-streak:nth-child(3) { top: 3px; left: 48px; width: 8px; } /* 10/4, 190/4, 30/4 */
|
||||||
|
.minifire-streak:nth-child(4) { top: 6px; width: 33px; } /* 22/4, 132/4 */
|
||||||
|
.minifire-streak:nth-child(5) { top: 6px; left: 36px; width: 12px; } /* 22/4, 142/4, 48/4 */
|
||||||
|
.minifire-streak:nth-child(6) { top: 6px; left: 50px; width: 7px; } /* 22/4, 200/4, 28/4 */
|
||||||
|
.minifire-streak:nth-child(7) { top: 9px; left: 19px; width: 40px; } /* 34/4, 74/4, 160/4 */
|
||||||
|
.minifire-streak:nth-child(8) { top: 12px; left: 28px; width: 10px; } /* 46/4, 110/4, 40/4 */
|
||||||
|
.minifire-streak:nth-child(9) { top: 12px; left: 43px; width: 14px; } /* 46/4, 170/4, 54/4 */
|
||||||
|
.minifire-streak:nth-child(10) { top: 15px; left: 23px; width: 28px; } /* 58/4, 90/4, 110/4 */
|
||||||
|
|
||||||
/* Streak positioning (unchanged from original) */
|
/* Scaled Log Positions (Relative to 150px campfire) */
|
||||||
.fire-loading-overlay .streak:nth-child(1) { top: 10px; width: 90px; }
|
.minifire-log:nth-child(1) { bottom: 25px; left: 25px; transform: rotate(150deg) scaleX(0.75); z-index: 20; } /* 100/4, 100/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(2) { top: 10px; left: 100px; width: 80px; }
|
.minifire-log:nth-child(2) { bottom: 30px; left: 35px; transform: rotate(110deg) scaleX(0.75); z-index: 10; } /* 120/4, 140/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(3) { top: 10px; left: 190px; width: 30px; }
|
.minifire-log:nth-child(3) { bottom: 25px; left: 17px; transform: rotate(-10deg) scaleX(0.75); } /* 98/4, 68/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(4) { top: 22px; width: 132px; }
|
.minifire-log:nth-child(4) { bottom: 20px; left: 55px; transform: rotate(-120deg) scaleX(0.75); z-index: 26; } /* 80/4, 220/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(5) { top: 22px; left: 142px; width: 48px; }
|
.minifire-log:nth-child(5) { bottom: 19px; left: 53px; transform: rotate(-30deg) scaleX(0.75); z-index: 25; } /* 75/4, 210/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(6) { top: 22px; left: 200px; width: 28px; }
|
.minifire-log:nth-child(6) { bottom: 23px; left: 70px; transform: rotate(35deg) scaleX(0.85); z-index: 30; } /* 92/4, 280/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(7) { top: 34px; left: 74px; width: 160px; }
|
.minifire-log:nth-child(7) { bottom: 18px; left: 75px; transform: rotate(-30deg) scaleX(0.75); z-index: 20; } /* 70/4, 300/4 */
|
||||||
.fire-loading-overlay .streak:nth-child(8) { top: 46px; left: 110px; width: 40px; }
|
|
||||||
.fire-loading-overlay .streak:nth-child(9) { top: 46px; left: 170px; width: 54px; }
|
|
||||||
.fire-loading-overlay .streak:nth-child(10) { top: 58px; left: 90px; width: 110px; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .log {
|
/* --- Scaled Down Sticks --- */
|
||||||
transform-origin: center center;
|
.minifire-stick {
|
||||||
box-shadow: 0 0 2px 1px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fire-loading-overlay .log:nth-child(1) { bottom: 100px; left: 100px; transform: rotate(150deg) scaleX(0.75); z-index: 20; }
|
|
||||||
.fire-loading-overlay .log:nth-child(2) { bottom: 120px; left: 140px; transform: rotate(110deg) scaleX(0.75); z-index: 10; }
|
|
||||||
.fire-loading-overlay .log:nth-child(3) { bottom: 98px; left: 68px; transform: rotate(-10deg) scaleX(0.75); }
|
|
||||||
.fire-loading-overlay .log:nth-child(4) { bottom: 80px; left: 220px; transform: rotate(-120deg) scaleX(0.75); z-index: 26; }
|
|
||||||
.fire-loading-overlay .log:nth-child(5) { bottom: 75px; left: 210px; transform: rotate(-30deg) scaleX(0.75); z-index: 25; }
|
|
||||||
.fire-loading-overlay .log:nth-child(6) { bottom: 92px; left: 280px; transform: rotate(35deg) scaleX(0.85); z-index: 30; }
|
|
||||||
.fire-loading-overlay .log:nth-child(7) { bottom: 70px; left: 300px; transform: rotate(-30deg) scaleX(0.75); z-index: 20; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .stick {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 68px;
|
width: 17px; /* 68/4 */
|
||||||
height: 20px;
|
height: 5px; /* 20/4 */
|
||||||
border-radius: 10px;
|
border-radius: 3px; /* 10/4 */
|
||||||
box-shadow: 0 0 2px 1px rgba(0,0,0,0.1);
|
box-shadow: 0 0 1px 0.5px rgba(0,0,0,0.1);
|
||||||
background: #781e20;
|
background: #781e20;
|
||||||
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
.minifire-stick:before {
|
||||||
.fire-loading-overlay .stick:before {
|
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
left: 30px;
|
left: 7px; /* 30/4 -> 7.5 */
|
||||||
width: 6px;
|
width: 1.5px; /* 6/4 */
|
||||||
height: 20px;
|
height: 5px; /* 20/4 */
|
||||||
background: #781e20;
|
background: #781e20;
|
||||||
border-radius: 10px;
|
border-radius: 3px; /* 10/4 */
|
||||||
transform: translateY(50%) rotate(32deg);
|
transform: translateY(50%) rotate(32deg);
|
||||||
}
|
}
|
||||||
|
.minifire-stick:after {
|
||||||
.fire-loading-overlay .stick:after {
|
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 20px;
|
width: 5px; /* 20/4 */
|
||||||
height: 20px;
|
height: 5px; /* 20/4 */
|
||||||
background: #b35050;
|
background: #b35050;
|
||||||
border-radius: 10px;
|
border-radius: 3px; /* 10/4 */
|
||||||
}
|
}
|
||||||
|
/* Scaled Stick Positions */
|
||||||
|
.minifire-stick:nth-child(1) { left: 40px; bottom: 41px; transform: rotate(-152deg) scaleX(0.8); z-index: 12; } /* 158/4, 164/4 */
|
||||||
|
.minifire-stick:nth-child(2) { left: 45px; bottom: 8px; transform: rotate(20deg) scaleX(0.9); } /* 180/4, 30/4 */
|
||||||
|
.minifire-stick:nth-child(3) { left: 100px; bottom: 10px; transform: rotate(170deg) scaleX(0.9); } /* 400/4, 38/4 */
|
||||||
|
.minifire-stick:nth-child(3):before { display: none; }
|
||||||
|
.minifire-stick:nth-child(4) { left: 93px; bottom: 38px; transform: rotate(80deg) scaleX(0.9); z-index: 20; } /* 370/4, 150/4 */
|
||||||
|
.minifire-stick:nth-child(4):before { display: none; }
|
||||||
|
|
||||||
.fire-loading-overlay .stick {
|
/* --- Scaled Down Fire --- */
|
||||||
transform-origin: center center;
|
.minifire-fire .minifire-flame {
|
||||||
}
|
|
||||||
|
|
||||||
.fire-loading-overlay .stick:nth-child(1) { left: 158px; bottom: 164px; transform: rotate(-152deg) scaleX(0.8); z-index: 12; }
|
|
||||||
.fire-loading-overlay .stick:nth-child(2) { left: 180px; bottom: 30px; transform: rotate(20deg) scaleX(0.9); }
|
|
||||||
.fire-loading-overlay .stick:nth-child(3) { left: 400px; bottom: 38px; transform: rotate(170deg) scaleX(0.9); }
|
|
||||||
.fire-loading-overlay .stick:nth-child(3):before { display: none; }
|
|
||||||
.fire-loading-overlay .stick:nth-child(4) { left: 370px; bottom: 150px; transform: rotate(80deg) scaleX(0.9); z-index: 20; }
|
|
||||||
.fire-loading-overlay .stick:nth-child(4):before { display: none; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .fire .flame {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform-origin: bottom center;
|
transform-origin: bottom center;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fire-loading-overlay .fire__red .flame {
|
/* Red Flames */
|
||||||
width: 48px;
|
.minifire-fire__red .minifire-flame {
|
||||||
border-radius: 48px;
|
width: 12px; /* 48/4 */
|
||||||
|
border-radius: 12px; /* 48/4 */
|
||||||
background: #e20f00;
|
background: #e20f00;
|
||||||
box-shadow: 0 0 80px 18px rgba(226,15,0,0.4);
|
box-shadow: 0 0 20px 5px rgba(226,15,0,0.4); /* Scaled shadow */
|
||||||
}
|
}
|
||||||
|
/* Scaled positions/heights */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(1) { left: 35px; height: 40px; bottom: 25px; animation: minifire-fire 2s 0.15s ease-in-out infinite alternate; } /* 138/4, 160/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(2) { left: 47px; height: 60px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; } /* 186/4, 240/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(3) { left: 59px; height: 75px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; } /* 234/4, 300/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(4) { left: 71px; height: 90px; bottom: 25px; animation: minifire-fire 2s 0s ease-in-out infinite alternate; } /* 282/4, 360/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(5) { left: 83px; height: 78px; bottom: 25px; animation: minifire-fire 2s 0.45s ease-in-out infinite alternate; } /* 330/4, 310/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(6) { left: 95px; height: 58px; bottom: 25px; animation: minifire-fire 2s 0.3s ease-in-out infinite alternate; } /* 378/4, 232/4, 100/4 */
|
||||||
|
.minifire-fire__red .minifire-flame:nth-child(7) { left: 107px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; } /* 426/4, 140/4, 100/4 */
|
||||||
|
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(1) { left: 138px; height: 160px; bottom: 100px; animation: fire 2s 0.15s ease-in-out infinite alternate; }
|
/* Orange Flames */
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(2) { left: 186px; height: 240px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; }
|
.minifire-fire__orange .minifire-flame {
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(3) { left: 234px; height: 300px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
|
width: 12px; border-radius: 12px; background: #ff9c00;
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(4) { left: 282px; height: 360px; bottom: 100px; animation: fire 2s 0s ease-in-out infinite alternate; }
|
box-shadow: 0 0 20px 5px rgba(255,156,0,0.4);
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(5) { left: 330px; height: 310px; bottom: 100px; animation: fire 2s 0.45s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(6) { left: 378px; height: 232px; bottom: 100px; animation: fire 2s 0.3s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__red .flame:nth-child(7) { left: 426px; height: 140px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .fire__orange .flame {
|
|
||||||
width: 48px;
|
|
||||||
border-radius: 48px;
|
|
||||||
background: #ff9c00;
|
|
||||||
box-shadow: 0 0 80px 18px rgba(255,156,0,0.4);
|
|
||||||
}
|
}
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(1) { left: 35px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.05s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(2) { left: 47px; height: 53px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(3) { left: 59px; height: 63px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(4) { left: 71px; height: 75px; bottom: 25px; animation: minifire-fire 2s 0.4s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(5) { left: 83px; height: 65px; bottom: 25px; animation: minifire-fire 2s 0.5s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(6) { left: 95px; height: 51px; bottom: 25px; animation: minifire-fire 2s 0.35s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__orange .minifire-flame:nth-child(7) { left: 107px; height: 28px; bottom: 25px; animation: minifire-fire 2s 0.1s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(1) { left: 138px; height: 140px; bottom: 100px; animation: fire 2s 0.05s ease-in-out infinite alternate; }
|
/* Yellow Flames */
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(2) { left: 186px; height: 210px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
|
.minifire-fire__yellow .minifire-flame {
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(3) { left: 234px; height: 250px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; }
|
width: 12px; border-radius: 12px; background: #ffeb6e;
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(4) { left: 282px; height: 300px; bottom: 100px; animation: fire 2s 0.4s ease-in-out infinite alternate; }
|
box-shadow: 0 0 20px 5px rgba(255,235,110,0.4);
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(5) { left: 330px; height: 260px; bottom: 100px; animation: fire 2s 0.5s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(6) { left: 378px; height: 202px; bottom: 100px; animation: fire 2s 0.35s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__orange .flame:nth-child(7) { left: 426px; height: 110px; bottom: 100px; animation: fire 2s 0.1s ease-in-out infinite alternate; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .fire__yellow .flame {
|
|
||||||
width: 48px;
|
|
||||||
border-radius: 48px;
|
|
||||||
background: #ffeb6e;
|
|
||||||
box-shadow: 0 0 80px 18px rgba(255,235,110,0.4);
|
|
||||||
}
|
}
|
||||||
|
.minifire-fire__yellow .minifire-flame:nth-child(1) { left: 47px; height: 35px; bottom: 25px; animation: minifire-fire 2s 0.6s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__yellow .minifire-flame:nth-child(2) { left: 59px; height: 43px; bottom: 30px; animation: minifire-fire 2s 0.4s ease-in-out infinite alternate; } /* Adjusted bottom slightly */
|
||||||
|
.minifire-fire__yellow .minifire-flame:nth-child(3) { left: 71px; height: 60px; bottom: 25px; animation: minifire-fire 2s 0.38s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__yellow .minifire-flame:nth-child(4) { left: 83px; height: 50px; bottom: 25px; animation: minifire-fire 2s 0.22s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__yellow .minifire-flame:nth-child(5) { left: 95px; height: 36px; bottom: 25px; animation: minifire-fire 2s 0.18s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
.fire-loading-overlay .fire__yellow .flame:nth-child(1) { left: 186px; height: 140px; bottom: 100px; animation: fire 2s 0.6s ease-in-out infinite alternate; }
|
/* White Flames */
|
||||||
.fire-loading-overlay .fire__yellow .flame:nth-child(2) { left: 234px; height: 172px; bottom: 120px; animation: fire 2s 0.4s ease-in-out infinite alternate; }
|
.minifire-fire__white .minifire-flame {
|
||||||
.fire-loading-overlay .fire__yellow .flame:nth-child(3) { left: 282px; height: 240px; bottom: 100px; animation: fire 2s 0.38s ease-in-out infinite alternate; }
|
width: 12px; border-radius: 12px; background: #fef1d9;
|
||||||
.fire-loading-overlay .fire__yellow .flame:nth-child(4) { left: 330px; height: 200px; bottom: 100px; animation: fire 2s 0.22s ease-in-out infinite alternate; }
|
box-shadow: 0 0 20px 5px rgba(254,241,217,0.4);
|
||||||
.fire-loading-overlay .fire__yellow .flame:nth-child(5) { left: 378px; height: 142px; bottom: 100px; animation: fire 2s 0.18s ease-in-out infinite alternate; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .fire__white .flame {
|
|
||||||
width: 48px;
|
|
||||||
border-radius: 48px;
|
|
||||||
background: #fef1d9;
|
|
||||||
box-shadow: 0 0 80px 18px rgba(254,241,217,0.4);
|
|
||||||
}
|
}
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(1) { left: 39px; width: 8px; height: 25px; bottom: 25px; animation: minifire-fire 2s 0.22s ease-in-out infinite alternate; } /* Scaled width too */
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(2) { left: 45px; width: 8px; height: 30px; bottom: 25px; animation: minifire-fire 2s 0.42s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(3) { left: 59px; height: 43px; bottom: 25px; animation: minifire-fire 2s 0.32s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(4) { left: 71px; height: 53px; bottom: 25px; animation: minifire-fire 2s 0.8s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(5) { left: 83px; height: 43px; bottom: 25px; animation: minifire-fire 2s 0.85s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(6) { left: 95px; width: 8px; height: 28px; bottom: 25px; animation: minifire-fire 2s 0.64s ease-in-out infinite alternate; }
|
||||||
|
.minifire-fire__white .minifire-flame:nth-child(7) { left: 102px; width: 8px; height: 25px; bottom: 25px; animation: minifire-fire 2s 0.32s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(1) { left: 156px; width: 32px; height: 100px; bottom: 100px; animation: fire 2s 0.22s ease-in-out infinite alternate; }
|
/* --- Scaled Down Sparks --- */
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(2) { left: 181px; width: 32px; height: 120px; bottom: 100px; animation: fire 2s 0.42s ease-in-out infinite alternate; }
|
.minifire-spark {
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(3) { left: 234px; height: 170px; bottom: 100px; animation: fire 2s 0.32s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(4) { left: 282px; height: 210px; bottom: 100px; animation: fire 2s 0.8s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(5) { left: 330px; height: 170px; bottom: 100px; animation: fire 2s 0.85s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(6) { left: 378px; width: 32px; height: 110px; bottom: 100px; animation: fire 2s 0.64s ease-in-out infinite alternate; }
|
|
||||||
.fire-loading-overlay .fire__white .flame:nth-child(7) { left: 408px; width: 32px; height: 100px; bottom: 100px; animation: fire 2s 0.32s ease-in-out infinite alternate; }
|
|
||||||
|
|
||||||
.fire-loading-overlay .spark {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 6px;
|
width: 1.5px; /* 6/4 */
|
||||||
height: 20px;
|
height: 5px; /* 20/4 */
|
||||||
background: #fef1d9;
|
background: #fef1d9;
|
||||||
border-radius: 18px;
|
border-radius: 5px; /* 18/4 -> 4.5 */
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
transform-origin: bottom center;
|
transform-origin: bottom center;
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
}
|
}
|
||||||
|
/* Scaled spark positions/animations */
|
||||||
|
.minifire-spark:nth-child(1) { left: 40px; bottom: 53px; animation: minifire-spark 1s 0.4s linear infinite; } /* 160/4, 212/4 */
|
||||||
|
.minifire-spark:nth-child(2) { left: 45px; bottom: 60px; animation: minifire-spark 1s 1s linear infinite; } /* 180/4, 240/4 */
|
||||||
|
.minifire-spark:nth-child(3) { left: 52px; bottom: 80px; animation: minifire-spark 1s 0.8s linear infinite; } /* 208/4, 320/4 */
|
||||||
|
.minifire-spark:nth-child(4) { left: 78px; bottom: 100px; animation: minifire-spark 1s 2s linear infinite; } /* 310/4, 400/4 */
|
||||||
|
.minifire-spark:nth-child(5) { left: 90px; bottom: 95px; animation: minifire-spark 1s 0.75s linear infinite; } /* 360/4, 380/4 */
|
||||||
|
.minifire-spark:nth-child(6) { left: 98px; bottom: 80px; animation: minifire-spark 1s 0.65s linear infinite; } /* 390/4, 320/4 */
|
||||||
|
.minifire-spark:nth-child(7) { left: 100px; bottom: 70px; animation: minifire-spark 1s 1s linear infinite; } /* 400/4, 280/4 */
|
||||||
|
.minifire-spark:nth-child(8) { left: 108px; bottom: 53px; animation: minifire-spark 1s 1.4s linear infinite; } /* 430/4, 210/4 */
|
||||||
|
|
||||||
.fire-loading-overlay .spark:nth-child(1) { left: 160px; bottom: 212px; animation: spark 1s 0.4s linear infinite; }
|
/* --- Keyframes (Rename to avoid conflicts) --- */
|
||||||
.fire-loading-overlay .spark:nth-child(2) { left: 180px; bottom: 240px; animation: spark 1s 1s linear infinite; }
|
/* Use the same keyframe logic, just rename them */
|
||||||
.fire-loading-overlay .spark:nth-child(3) { left: 208px; bottom: 320px; animation: spark 1s 0.8s linear infinite; }
|
@keyframes minifire-fire {
|
||||||
.fire-loading-overlay .spark:nth-child(4) { left: 310px; bottom: 400px; animation: spark 1s 2s linear infinite; }
|
0% { transform: scaleY(1); } 28% { transform: scaleY(0.7); } 38% { transform: scaleY(0.8); } 50% { transform: scaleY(0.6); } 70% { transform: scaleY(0.95); } 82% { transform: scaleY(0.58); } 100% { transform: scaleY(1); }
|
||||||
.fire-loading-overlay .spark:nth-child(5) { left: 360px; bottom: 380px; animation: spark 1s 0.75s linear infinite; }
|
|
||||||
.fire-loading-overlay .spark:nth-child(6) { left: 390px; bottom: 320px; animation: spark 1s 0.65s linear infinite; }
|
|
||||||
.fire-loading-overlay .spark:nth-child(7) { left: 400px; bottom: 280px; animation: spark 1s 1s linear infinite; }
|
|
||||||
.fire-loading-overlay .spark:nth-child(8) { left: 430px; bottom: 210px; animation: spark 1s 1.4s linear infinite; }
|
|
||||||
|
|
||||||
@keyframes fire {
|
|
||||||
0% { transform: scaleY(1); }
|
|
||||||
28% { transform: scaleY(0.7); }
|
|
||||||
38% { transform: scaleY(0.8); }
|
|
||||||
50% { transform: scaleY(0.6); }
|
|
||||||
70% { transform: scaleY(0.95); }
|
|
||||||
82% { transform: scaleY(0.58); }
|
|
||||||
100% { transform: scaleY(1); }
|
|
||||||
}
|
}
|
||||||
|
@keyframes minifire-spark {
|
||||||
@keyframes spark {
|
|
||||||
0%, 35% { transform: scaleY(0) translateY(0); opacity: 0; }
|
0%, 35% { transform: scaleY(0) translateY(0); opacity: 0; }
|
||||||
50% { transform: scaleY(1) translateY(0); opacity: 1; }
|
50% { transform: scaleY(1) translateY(0); opacity: 1; }
|
||||||
70% { transform: scaleY(1) translateY(-10px); opacity: 1; }
|
/* Adjusted translateY for smaller scale */
|
||||||
75% { transform: scaleY(1) translateY(-10px); opacity: 0; }
|
70% { transform: scaleY(1) translateY(-3px); opacity: 1; } /* 10/4 -> 2.5 -> 3 */
|
||||||
|
75% { transform: scaleY(1) translateY(-3px); opacity: 0; }
|
||||||
100% { transform: scaleY(0) translateY(0); opacity: 0; }
|
100% { transform: scaleY(0) translateY(0); opacity: 0; }
|
||||||
}
|
}
|
1
static/js/htmx.min.js
vendored
Normal file
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="{% if request.cookies.get('theme') == 'dark' %}dark{% else %}light{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@ -10,8 +10,13 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/animation.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/animation.css') }}">
|
||||||
|
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||||
<title>{% if app_mode == 'lite' %}(Lite Version) {% endif %}{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
|
<title>{% if app_mode == 'lite' %}(Lite Version) {% endif %}{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent flash of unstyled content */
|
||||||
|
:root { visibility: hidden; }
|
||||||
|
[data-theme="dark"], [data-theme="light"] { visibility: visible; }
|
||||||
|
|
||||||
/* Default (Light Theme) Styles */
|
/* Default (Light Theme) Styles */
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
@ -88,7 +93,7 @@
|
|||||||
height: 1.25rem !important;
|
height: 1.25rem !important;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
border: 1px solide #6c757d;
|
border: 1px solid #6c757d;
|
||||||
}
|
}
|
||||||
.form-check-input:checked {
|
.form-check-input:checked {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
@ -289,20 +294,18 @@
|
|||||||
display: block; /* Make code fill pre */
|
display: block; /* Make code fill pre */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Dark Theme Override */
|
/* Dark Theme Override */
|
||||||
html[data-theme="dark"] #fsh-output pre,
|
html[data-theme="dark"] #fsh-output pre,
|
||||||
html[data-theme="dark"] ._fsh_output pre
|
html[data-theme="dark"] ._fsh_output pre {
|
||||||
{
|
|
||||||
background-color: #495057; /* Dark theme background */
|
background-color: #495057; /* Dark theme background */
|
||||||
color: #f8f9fa; /* Dark theme text */
|
color: #f8f9fa; /* Dark theme text */
|
||||||
border-color: #6c757d;
|
border-color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] #fsh-output pre code,
|
html[data-theme="dark"] #fsh-output pre code,
|
||||||
html[data-theme="dark"] ._fsh_output pre code {
|
html[data-theme="dark"] ._fsh_output pre code {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Theme Styles for FHIR UI Operations (fhir_ui_operations.html) --- */
|
/* --- Theme Styles for FHIR UI Operations (fhir_ui_operations.html) --- */
|
||||||
|
|
||||||
@ -477,7 +480,6 @@
|
|||||||
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: green"] { color: #20c997 !important; }
|
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: green"] { color: #20c997 !important; }
|
||||||
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: red"] { color: #e63946 !important; }
|
html[data-theme="dark"] .execute-wrapper .response-status[style*="color: red"] { color: #e63946 !important; }
|
||||||
|
|
||||||
|
|
||||||
/* Specific Action Buttons (Success, Danger, Warning, Info) */
|
/* Specific Action Buttons (Success, Danger, Warning, Info) */
|
||||||
.btn-success { background-color: #198754; border-color: #198754; color: white; }
|
.btn-success { background-color: #198754; border-color: #198754; color: white; }
|
||||||
.btn-success:hover { background-color: #157347; border-color: #146c43; }
|
.btn-success:hover { background-color: #157347; border-color: #146c43; }
|
||||||
@ -752,7 +754,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column min-vh-100">
|
<body class="d-flex flex-column min-vh-100 {% block body_class %}{% endblock %}">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light">
|
<nav class="navbar navbar-expand-lg navbar-light">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}{% if app_mode == 'lite' %} (Lite){% endif %}</a>
|
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}{% if app_mode == 'lite' %} (Lite){% endif %}</a>
|
||||||
@ -765,10 +767,13 @@
|
|||||||
<a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="fas fa-house me-1"></i> Home</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'index' else '' }}" aria-current="page" href="{{ url_for('index') }}"><i class="fas fa-house me-1"></i> Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a>
|
<a class="nav-link {% if request.path == '/search-and-import' %}active{% endif %}" href="{{ url_for('search_and_import') }}"><i class="fas fa-download me-1"></i> Search and Import</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<!-- <li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'import_ig' else '' }}" href="{{ url_for('import_ig') }}"><i class="fas fa-download me-1"></i> Import IGs</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'import_ig' else '' }}" href="{{ url_for('import_ig') }}"><i class="fas fa-download me-1"></i> Import IGs</a>
|
||||||
|
</li> -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="fas fa-upload me-1"></i> Push IGs</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'push_igs' else '' }}" href="{{ url_for('push_igs') }}"><i class="fas fa-upload me-1"></i> Push IGs</a>
|
||||||
@ -779,6 +784,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="fas fa-check-circle me-1"></i> Validate FHIR Sample</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'validate_sample' else '' }}" href="{{ url_for('validate_sample') }}"><i class="fas fa-check-circle me-1"></i> Validate FHIR Sample</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint == 'retrieve_split_data' else '' }}" href="{{ url_for('retrieve_split_data') }}"><i class="fas bi-cloud-download me-1"></i> Retrieve & Split Data</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui' else '' }}" href="{{ url_for('fhir_ui') }}"><i class="fas fa-cloud-download-alt me-1"></i> FHIR API Explorer</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'fhir_ui' else '' }}" href="{{ url_for('fhir_ui') }}"><i class="fas fa-cloud-download-alt me-1"></i> FHIR API Explorer</a>
|
||||||
</li>
|
</li>
|
||||||
@ -788,6 +796,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
|
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if app_mode != 'lite' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="navbar-controls d-flex align-items-center">
|
<div class="navbar-controls d-flex align-items-center">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
@ -849,41 +862,38 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
<script>
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
const toggle = document.getElementById('themeToggle');
|
const toggle = document.getElementById('themeToggle');
|
||||||
const sunIcon = document.querySelector('.fa-sun');
|
const sunIcon = document.querySelector('.fa-sun');
|
||||||
const moonIcon = document.querySelector('.fa-moon');
|
const moonIcon = document.querySelector('.fa-moon');
|
||||||
if (toggle.checked) {
|
const newTheme = toggle.checked ? 'dark' : 'light';
|
||||||
html.setAttribute('data-theme', 'dark');
|
html.setAttribute('data-theme', newTheme);
|
||||||
localStorage.setItem('theme', 'dark');
|
localStorage.setItem('theme', newTheme);
|
||||||
sunIcon.classList.add('d-none');
|
sunIcon.classList.toggle('d-none', toggle.checked);
|
||||||
moonIcon.classList.remove('d-none');
|
moonIcon.classList.toggle('d-none', !toggle.checked);
|
||||||
} else {
|
document.cookie = `theme=${newTheme}; path=/; max-age=31536000`;
|
||||||
html.removeAttribute('data-theme');
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
sunIcon.classList.remove('d-none');
|
|
||||||
moonIcon.classList.add('d-none');
|
|
||||||
}
|
|
||||||
if (typeof loadLottieAnimation === 'function') {
|
if (typeof loadLottieAnimation === 'function') {
|
||||||
const newTheme = toggle.checked ? 'dark' : 'light';
|
|
||||||
loadLottieAnimation(newTheme);
|
loadLottieAnimation(newTheme);
|
||||||
}
|
}
|
||||||
// Set cookie to persist theme
|
|
||||||
document.cookie = `theme=${toggle.checked ? 'dark' : 'light'}; path=/; max-age=31536000`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const savedTheme = localStorage.getItem('theme') || (document.cookie.includes('theme=dark') ? 'dark' : 'light');
|
const savedTheme = localStorage.getItem('theme') || getCookie('theme') || 'light';
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
const sunIcon = document.querySelector('.fa-sun');
|
const sunIcon = document.querySelector('.fa-sun');
|
||||||
const moonIcon = document.querySelector('.fa-moon');
|
const moonIcon = document.querySelector('.fa-moon');
|
||||||
if (savedTheme === 'dark' && themeToggle) {
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
themeToggle.checked = savedTheme === 'dark';
|
||||||
themeToggle.checked = true;
|
sunIcon.classList.toggle('d-none', savedTheme === 'dark');
|
||||||
sunIcon.classList.add('d-none');
|
moonIcon.classList.toggle('d-none', savedTheme !== 'dark');
|
||||||
moonIcon.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
tooltips.forEach(t => new bootstrap.Tooltip(t));
|
tooltips.forEach(t => new bootstrap.Tooltip(t));
|
||||||
});
|
});
|
||||||
|
234
templates/config_hapi.html
Normal file
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fire Animation Loading Overlay -->
|
|
||||||
<div id="fire-loading-overlay" class="fire-loading-overlay d-none">
|
<div id="fire-loading-overlay" class="fire-loading-overlay d-none">
|
||||||
<div class="campfire">
|
<div class="campfire">
|
||||||
<div class="sparks">
|
<div class="campfire-scaler">
|
||||||
<div class="spark"></div>
|
<div class="sparks">
|
||||||
<div class="spark"></div>
|
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
|
||||||
<div class="spark"></div>
|
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
|
||||||
<div class="spark"></div>
|
|
||||||
<div class="spark"></div>
|
|
||||||
<div class="spark"></div>
|
|
||||||
<div class="spark"></div>
|
|
||||||
<div class="spark"></div>
|
|
||||||
</div>
|
|
||||||
<div class="logs">
|
|
||||||
<div class="log">
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="log">
|
<div class="logs">
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="log">
|
<div class="sticks">
|
||||||
<div class="streak"></div>
|
<div class="stick"></div><div class="stick"></div><div class="stick"></div><div class="stick"></div>
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="log">
|
<div class="fire">
|
||||||
<div class="streak"></div>
|
<div class="fire__red"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="fire__orange"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="fire__yellow"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||||
<div class="streak"></div>
|
<div class="fire__white"><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div><div class="flame"></div></div>
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="log">
|
</div> </div> <div class="loading-text text-center">
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
|
||||||
<div class="log">
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
|
||||||
<div class="log">
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
<div class="streak"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sticks">
|
|
||||||
<div class="stick"></div>
|
|
||||||
<div class="stick"></div>
|
|
||||||
<div class="stick"></div>
|
|
||||||
<div class="stick"></div>
|
|
||||||
</div>
|
|
||||||
<div class="fire">
|
|
||||||
<div class="fire__red">
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
</div>
|
|
||||||
<div class="fire__orange">
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
</div>
|
|
||||||
<div class="fire__yellow">
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
</div>
|
|
||||||
<div class="fire__white">
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
<div class="flame"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center mt-3">
|
|
||||||
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
|
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
|
||||||
<p id="log-display" class="text-white mb-0"></p>
|
<p id="log-display" class="text-white mb-0"></p>
|
||||||
</div>
|
</div>
|
||||||
@ -183,82 +66,80 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// Your existing JS here (exactly as provided before)
|
||||||
const form = document.getElementById('import-ig-form');
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const form = document.getElementById('import-ig-form');
|
||||||
const loadingOverlay = document.getElementById('fire-loading-overlay');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
const logDisplay = document.getElementById('log-display');
|
const loadingOverlay = document.getElementById('fire-loading-overlay');
|
||||||
|
const logDisplay = document.getElementById('log-display');
|
||||||
|
|
||||||
if (form && submitBtn && loadingOverlay && logDisplay) {
|
if (form && submitBtn && loadingOverlay && logDisplay) {
|
||||||
form.addEventListener('submit', async function(event) {
|
form.addEventListener('submit', async function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
logDisplay.textContent = 'Starting import...';
|
logDisplay.textContent = 'Starting import...';
|
||||||
loadingOverlay.classList.remove('d-none');
|
loadingOverlay.classList.remove('d-none'); // Show the overlay
|
||||||
|
|
||||||
let eventSource;
|
let eventSource;
|
||||||
try {
|
try {
|
||||||
// Start SSE connection for logs
|
eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
|
||||||
eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
|
eventSource.onmessage = function(event) {
|
||||||
|
if (event.data === '[DONE]') {
|
||||||
|
eventSource.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logDisplay.textContent = event.data;
|
||||||
|
};
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
console.error('SSE connection error');
|
||||||
|
if(eventSource) eventSource.close();
|
||||||
|
logDisplay.textContent = 'Log streaming interrupted.';
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize SSE:', e);
|
||||||
|
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
eventSource.onmessage = function(event) {
|
const formData = new FormData(form);
|
||||||
if (event.data === '[DONE]') {
|
try {
|
||||||
|
const response = await fetch('{{ url_for('import_ig') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
logDisplay.textContent = event.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = function() {
|
if (!response.ok) {
|
||||||
console.error('SSE connection error');
|
const errorData = await response.json().catch(() => ({ message: `HTTP error: ${response.status}` }));
|
||||||
eventSource.close();
|
throw new Error(errorData.message || `HTTP error: ${response.status}`);
|
||||||
logDisplay.textContent = 'Log streaming interrupted. Import may still be in progress.';
|
}
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to initialize SSE:', e);
|
|
||||||
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit form via AJAX
|
const data = await response.json();
|
||||||
const formData = new FormData(form);
|
if (data.redirect) {
|
||||||
try {
|
window.location.href = data.redirect;
|
||||||
const response = await fetch('{{ url_for('import_ig') }}', {
|
} else {
|
||||||
method: 'POST',
|
// Optional: display success message
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
}
|
||||||
body: formData
|
} catch (error) {
|
||||||
});
|
console.error('Import error:', error);
|
||||||
|
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||||
if (eventSource) eventSource.close();
|
eventSource.close();
|
||||||
|
}
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.message || `HTTP error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.redirect) {
|
|
||||||
window.location.href = data.redirect;
|
|
||||||
} else {
|
|
||||||
const alertDiv = document.createElement('div');
|
const alertDiv = document.createElement('div');
|
||||||
alertDiv.className = 'alert alert-success mt-3';
|
alertDiv.className = 'alert alert-danger mt-3';
|
||||||
alertDiv.textContent = data.message || 'Import successful!';
|
alertDiv.textContent = `Import failed: ${error.message}`;
|
||||||
form.parentElement.appendChild(alertDiv);
|
form.closest('.card-body').appendChild(alertDiv);
|
||||||
|
} finally {
|
||||||
|
loadingOverlay.classList.add('d-none'); // Hide overlay when done
|
||||||
|
submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Import error:', error);
|
} else {
|
||||||
if (eventSource) eventSource.close();
|
console.error('Required elements missing for import script.');
|
||||||
const alertDiv = document.createElement('div');
|
}
|
||||||
alertDiv.className = 'alert alert-danger mt-3';
|
});
|
||||||
alertDiv.textContent = `Import failed: ${error.message}`;
|
|
||||||
form.parentElement.appendChild(alertDiv);
|
|
||||||
} finally {
|
|
||||||
loadingOverlay.classList.add('d-none');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Required elements missing: form, submit button, loading overlay, or log display.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,5 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block body_class %}fire-animation-page{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="px-4 py-5 my-5 text-center">
|
<div class="px-4 py-5 my-5 text-center">
|
||||||
<div id="logo-container" class="mb-4">
|
<div id="logo-container" class="mb-4">
|
||||||
@ -59,7 +61,8 @@
|
|||||||
<h5 class="card-title">IG Management</h5>
|
<h5 class="card-title">IG Management</h5>
|
||||||
<p class="card-text">Import and manage FHIR Implementation Guides.</p>
|
<p class="card-text">Import and manage FHIR Implementation Guides.</p>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a>
|
<!-- <a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a> -->
|
||||||
|
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary">Search & Import IGs</a>
|
||||||
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a>
|
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a>
|
||||||
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
|
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
|
||||||
</div>
|
</div>
|
||||||
@ -75,6 +78,7 @@
|
|||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a>
|
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a>
|
||||||
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a>
|
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a>
|
||||||
|
<a href="{{ url_for('retrieve_split_data') }}" class="btn btn-outline-secondary">Retrieve & Split Data</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
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