Compare commits

...

27 Commits
V1.6 ... main

Author SHA1 Message Date
5c559f9652
Merge pull request #17 from Sudo-JHare/Patch
QOL Changes
2025-05-22 20:08:26 +10:00
fa091db463 QOL Changes
added Basic Auth options for custom server  usage across multiple modules.

new footer option - OpenAPI docs - integrated swagger UI for open API documentation
2025-05-22 20:07:11 +10:00
0506d76ea9 patch
Typo fix in template
2025-05-14 10:35:15 +10:00
d9ac3dc05b Merge branch 'main' of https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit 2025-05-14 10:25:50 +10:00
431bfb5a46 patch
Typo fix in template
2025-05-14 10:23:37 +10:00
d2e05ee4a0
Merge pull request #15 from Sudo-JHare/Patch
Patched
2025-05-14 10:06:08 +10:00
6412e537a8 Patched
Patched for New IG processing, to handle non standard structures, names, and versions formats in IG's
2025-05-14 09:59:53 +10:00
b6f9d2697e Update app.py 2025-05-13 15:38:02 +10:00
11e8569c56 patch 2025-05-13 14:03:29 +10:00
f150e7baaf path patch 2025-05-13 14:00:48 +10:00
f91af1148d patch 2025-05-13 13:43:23 +10:00
179982dedc Dynamic Hostname fix
Patch for dynamic host names
2025-05-13 12:37:04 +10:00
5efae0c64e Revert "Add files via upload"
This reverts commit f172b00737092fdf4040bffd2f8c6c2742eb7a44.
2025-05-11 22:32:57 +10:00
f172b00737
Add files via upload 2025-05-11 21:14:27 +10:00
b08f4e5790
Update setup_linux.sh 2025-05-08 21:27:00 +10:00
7368f42e8b
Create setup_linux.sh 2025-05-08 21:06:18 +10:00
7049a902a4
Merge pull request #14 from Sudo-JHare/Patch
Update README.md
2025-05-07 07:41:18 +10:00
84b3df524f Update README.md 2025-05-07 07:40:43 +10:00
6bca03aa7c
Merge pull request #12 from Sudo-JHare/Patch
V2.0 release.
Hapi Config Page.
retrieve bundles from server
split bundles to resources.
new upload page, and Package Registry CACHE
2025-05-04 22:58:12 +10:00
ca8668e5e5 V2.0 release.
Hapi Config Page.
retrieve bundles from server
split bundles to resources.
new upload page, and Package Registry CACHE
2025-05-04 22:55:36 +10:00
ea97f49ead
Merge pull request #11 from Sudo-JHare/Patch
Easter egg patch
2025-05-01 09:23:35 +10:00
cd06b1b216 Easter egg patch
easter egg patch

Co-Authored-By: Sudo-Xops <209843921+Sudo-Xops@users.noreply.github.com>
2025-05-01 09:22:17 +10:00
2efe4cbb2f
Merge pull request #10 from Sudo-JHare/Patch
Patch
2025-05-01 07:17:52 +10:00
dfa0ef31b2 Update README_INTEGRATION FHIRVINE as Moduel in FLARE.md
Co-Authored-By: Sudo-Xops <209843921+sudo-xops@users.noreply.github.com>
2025-05-01 07:15:59 +10:00
ca8613ff1b Tidy-up patch 2025-05-01 07:00:57 +10:00
89ef0de20c QOL patch
added easter egg on home page
reformatted homepage
added IG loading animation and console output to the animation
2025-04-30 22:13:14 +10:00
cfe4f3dd4f BugFix 2025-04-26 22:51:52 +10:00
50 changed files with 125642 additions and 4329 deletions

View File

@ -157,6 +157,8 @@ echo Updating docker-compose.yml with APP_MODE=%APP_MODE%...
echo - FLASK_ENV=development echo - FLASK_ENV=development
echo - NODE_PATH=/usr/lib/node_modules echo - NODE_PATH=/usr/lib/node_modules
echo - APP_MODE=%APP_MODE% echo - APP_MODE=%APP_MODE%
echo - APP_BASE_URL=http://localhost:5000
echo - HAPI_FHIR_URL=http://localhost:8080/fhir
echo command: supervisord -c /etc/supervisord.conf echo command: supervisord -c /etc/supervisord.conf
) > docker-compose.yml.tmp ) > docker-compose.yml.tmp

View File

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

375
README.md
View File

@ -1,8 +1,9 @@
# FHIRFLARE IG Toolkit # FHIRFLARE IG Toolkit
![FHIRFLARE Logo](static/FHIRFLARE.png)
## Overview ## Overview
The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), and uploading complex test data sets with dependency management. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers. The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), uploading complex test data sets with dependency management, and retrieving/splitting FHIR bundles. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers.
The application can run in two modes: The application can run in two modes:
@ -30,6 +31,12 @@ This toolkit offers two primary installation modes to suit different needs:
## Features ## Features
* **Import IGs:** Download FHIR IG packages and dependencies from a package registry, supporting flexible version formats (e.g., `1.2.3`, `1.1.0-preview`, `current`) and dependency pulling modes (Recursive, Patch Canonical, Tree Shaking). * **Import IGs:** Download FHIR IG packages and dependencies from a package registry, supporting flexible version formats (e.g., `1.2.3`, `1.1.0-preview`, `current`) and dependency pulling modes (Recursive, Patch Canonical, Tree Shaking).
* **Enhanced Package Search and Import:**
* Interactive page (`/search-and-import`) to search for FHIR IG packages from configured registries.
* Displays package details, version history, dependencies, and dependents.
* Utilizes a local database cache (`CachedPackage`) for faster subsequent searches.
* Background task to refresh the package cache from registries (`/api/refresh-cache-task`).
* Direct import from search results.
* **Manage IGs:** View, process, unload, or delete downloaded IGs, with duplicate detection and resolution. * **Manage IGs:** View, process, unload, or delete downloaded IGs, with duplicate detection and resolution.
* **Process IGs:** Extract resource types, profiles, must-support elements, examples, and profile relationships (`structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile`). * **Process IGs:** Extract resource types, profiles, must-support elements, examples, and profile relationships (`structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile`).
* **Validate FHIR Resources/Bundles:** Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature). *Note: Lite version uses local SD checks only.* * **Validate FHIR Resources/Bundles:** Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature). *Note: Lite version uses local SD checks only.*
@ -55,9 +62,19 @@ This toolkit offers two primary installation modes to suit different needs:
* Handles large numbers of files using a custom form parser. * Handles large numbers of files using a custom form parser.
* **Profile Relationships:** Display and validate `compliesWithProfile` and `imposeProfile` extensions in the UI (configurable). * **Profile Relationships:** Display and validate `compliesWithProfile` and `imposeProfile` extensions in the UI (configurable).
* **FSH Converter:** Convert FHIR JSON/XML resources to FHIR Shorthand (FSH) using GoFSH, with advanced options (Package context, Output styles, Log levels, FHIR versions, Fishing Trip, Dependencies, Indentation, Meta Profile handling, Alias File, No Alias). Includes a waiting spinner. * **FSH Converter:** Convert FHIR JSON/XML resources to FHIR Shorthand (FSH) using GoFSH, with advanced options (Package context, Output styles, Log levels, FHIR versions, Fishing Trip, Dependencies, Indentation, Meta Profile handling, Alias File, No Alias). Includes a waiting spinner.
* **Retrieve and Split Bundles:**
* Retrieve specified resource types as bundles from a FHIR server.
* Optionally fetch referenced resources, either individually or as full bundles for each referenced type.
* Split uploaded ZIP files containing bundles into individual resource JSON files.
* Download retrieved/split resources as a ZIP archive.
* Streaming progress log via the UI for retrieval operations.
* **FHIR Interaction UIs:** Explore FHIR server capabilities and interact with resources using the "FHIR API Explorer" (simple GET/POST/PUT/DELETE) and "FHIR UI Operations" (Swagger-like interface based on CapabilityStatement). *Note: Lite version requires custom server URLs.* * **FHIR Interaction UIs:** Explore FHIR server capabilities and interact with resources using the "FHIR API Explorer" (simple GET/POST/PUT/DELETE) and "FHIR UI Operations" (Swagger-like interface based on CapabilityStatement). *Note: Lite version requires custom server URLs.*
* **API Support:** RESTful API endpoints for importing, pushing, retrieving metadata, validating, and uploading test data. * **HAPI FHIR Configuration (Standalone Mode):**
* **Live Console:** Real-time logs for push, validation, upload test data, and FSH conversion operations. * A dedicated page (`/config-hapi`) to view and edit the `application.yaml` configuration for the embedded HAPI FHIR server.
* Allows modification of HAPI FHIR properties directly from the UI.
* Option to restart the HAPI FHIR server (Tomcat) to apply changes.
* **API Support:** RESTful API endpoints for importing, pushing, retrieving metadata, validating, uploading test data, and retrieving/splitting bundles.
* **Live Console:** Real-time logs for push, validation, upload test data, FSH conversion, and bundle retrieval operations.
* **Configurable Behavior:** Control validation modes, display options via `app.config`. * **Configurable Behavior:** Control validation modes, display options via `app.config`.
* **Theming:** Supports light and dark modes. * **Theming:** Supports light and dark modes.
@ -101,9 +118,10 @@ docker run -d \
-v ./logs:/app/logs \ -v ./logs:/app/logs \
--name fhirflare-lite \ --name fhirflare-lite \
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
Standalone Version (Includes local HAPI FHIR): Standalone Version (Includes local HAPI FHIR):
Bash
# Pull the latest Standalone image # Pull the latest Standalone image
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
@ -119,7 +137,6 @@ docker run -d \
-v ./logs:/app/logs \ -v ./logs:/app/logs \
--name fhirflare-standalone \ --name fhirflare-standalone \
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
Building from Source (Developers) Building from Source (Developers)
Using Windows .bat Scripts (Standalone Version Only): Using Windows .bat Scripts (Standalone Version Only):
@ -127,258 +144,252 @@ First Time Setup:
Run Build and Run for first time.bat: Run Build and Run for first time.bat:
Code snippet
cd "<project folder>" cd "<project folder>"
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
copy .\hapi-fhir-Setup\target\classes\application.yaml .\hapi-fhir-jpaserver\target\classes\application.yaml copy .\\hapi-fhir-Setup\\target\\classes\\application.yaml .\\hapi-fhir-jpaserver\\target\\classes\\application.yaml
mvn clean package -DskipTests=true -Pboot mvn clean package -DskipTests=true -Pboot
docker-compose build --no-cache docker-compose build --no-cache
docker-compose up -d docker-compose up -d
This clones the HAPI FHIR server, copies configuration, builds the project, and starts the containers. This clones the HAPI FHIR server, copies configuration, builds the project, and starts the containers.
Subsequent Runs: Subsequent Runs:
Run Run.bat: Run Run.bat:
Code snippet
cd "<project folder>" cd "<project folder>"
docker-compose up -d docker-compose up -d
This starts the Flask app (port 5000) and HAPI FHIR server (port 8080). This starts the Flask app (port 5000) and HAPI FHIR server (port 8080).
Access the Application: Access the Application:
Flask UI: http://localhost:5000 Flask UI: http://localhost:5000
HAPI FHIR server: http://localhost:8080 HAPI FHIR server: http://localhost:8080
Manual Setup (Linux/MacOS/Windows): Manual Setup (Linux/MacOS/Windows):
Preparation (Standalone Version Only): Preparation (Standalone Version Only):
Bash
cd <project folder> cd <project folder>
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
cp ./hapi-fhir-Setup/target/classes/application.yaml ./hapi-fhir-jpaserver/target/classes/application.yaml cp ./hapi-fhir-Setup/target/classes/application.yaml ./hapi-fhir-jpaserver/target/classes/application.yaml
Build: Build:
Bash
# Build HAPI FHIR (Standalone Version Only) # Build HAPI FHIR (Standalone Version Only)
mvn clean package -DskipTests=true -Pboot mvn clean package -DskipTests=true -Pboot
# Build Docker Image (Specify APP_MODE=lite in docker-compose.yml for Lite version) # Build Docker Image (Specify APP_MODE=lite in docker-compose.yml for Lite version)
docker-compose build --no-cache docker-compose build --no-cache
Run: Run:
docker-compose up -d Bash
docker-compose up -d
Access the Application: Access the Application:
Flask UI: http://localhost:5000 Flask UI: http://localhost:5000
HAPI FHIR server (Standalone only): http://localhost:8080 HAPI FHIR server (Standalone only): http://localhost:8080
Local Development (Without Docker): Local Development (Without Docker):
Clone the Repository: Clone the Repository:
Bash
git clone [https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git](https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git) git clone [https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git](https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git)
cd FHIRFLARE-IG-Toolkit cd FHIRFLARE-IG-Toolkit
Install Dependencies: Install Dependencies:
Bash
python -m venv venv python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
Install Node.js, GoFSH, and SUSHI (for FSH Converter): Install Node.js, GoFSH, and SUSHI (for FSH Converter):
Bash
# Example for Debian/Ubuntu # Example for Debian/Ubuntu
curl -fsSL [https://deb.nodesource.com/setup_18.x](https://deb.nodesource.com/setup_18.x) | sudo bash - curl -fsSL [https://deb.nodesource.com/setup_18.x](https://deb.nodesource.com/setup_18.x) | sudo bash -
sudo apt-get install -y nodejs sudo apt-get install -y nodejs
# Install globally # Install globally
npm install -g gofsh fsh-sushi npm install -g gofsh fsh-sushi
Set Environment Variables: Set Environment Variables:
Bash
export FLASK_SECRET_KEY='your-secure-secret-key' export FLASK_SECRET_KEY='your-secure-secret-key'
export API_KEY='your-api-key' export API_KEY='your-api-key'
# Optional: Set APP_MODE to 'lite' if desired # Optional: Set APP_MODE to 'lite' if desired
# export APP_MODE='lite' # export APP_MODE='lite'
Initialize Directories: Initialize Directories:
Bash
mkdir -p instance static/uploads logs mkdir -p instance static/uploads logs
# Ensure write permissions if needed # Ensure write permissions if needed
# chmod -R 777 instance static/uploads logs # chmod -R 777 instance static/uploads logs
Run the Application: Run the Application:
Bash
export FLASK_APP=app.py export FLASK_APP=app.py
flask run flask run
Access at http://localhost:5000. Access at http://localhost:5000.
Usage Usage
Import an IG Import an IG
Navigate to Import IG (/import-ig). ### Search, View Details, and Import Packages
Navigate to **Search and Import Packages** (`/search-and-import`).
1. The page will load a list of available FHIR Implementation Guide packages from a local cache or by fetching from configured registries.
* A loading animation and progress messages are shown if fetching from registries.
* The timestamp of the last cache update is displayed.
2. Use the search bar to filter packages by name or author.
3. Packages are paginated for easier Browse.
4. For each package, you can:
* View its latest official and absolute versions.
* Click on the package name to navigate to a **detailed view** (`/package-details/<name>`) showing:
* Comprehensive metadata (author, FHIR version, canonical URL, description).
* A full list of available versions with publication dates.
* Declared dependencies.
* Other packages that depend on it (dependents).
* Version history (logs).
* Directly import a specific version using the "Import" button on the search page or the details page.
5. **Cache Management:**
* A "Clear & Refresh Cache" button is available to trigger a background task (`/api/refresh-cache-task`) that clears the local database and in-memory cache and fetches the latest package information from all configured registries. Progress is shown via a live log.
Enter a package name (e.g., hl7.fhir.au.core) and version (e.g., 1.1.0-preview). Enter a package name (e.g., hl7.fhir.au.core) and version (e.g., 1.1.0-preview).
Choose a dependency mode: Choose a dependency mode:
Current Recursive: Import all dependencies listed in package.json recursively. Current Recursive: Import all dependencies listed in package.json recursively.
Patch Canonical Versions: Import only canonical FHIR packages (e.g., hl7.fhir.r4.core). Patch Canonical Versions: Import only canonical FHIR packages (e.g., hl7.fhir.r4.core).
Tree Shaking: Import only dependencies containing resources actually used by the main package. Tree Shaking: Import only dependencies containing resources actually used by the main package.
Click Import to download the package and dependencies. Click Import to download the package and dependencies.
Manage IGs Manage IGs
Go to Manage FHIR Packages (/view-igs) to view downloaded and processed IGs. Go to Manage FHIR Packages (/view-igs) to view downloaded and processed IGs.
Actions: Actions:
Process: Extract metadata (resource types, profiles, must-support elements, examples). Process: Extract metadata (resource types, profiles, must-support elements, examples).
Unload: Remove processed IG data from the database. Unload: Remove processed IG data from the database.
Delete: Remove package files from the filesystem. Delete: Remove package files from the filesystem.
Duplicates are highlighted for resolution. Duplicates are highlighted for resolution.
View Processed IGs View Processed IGs
After processing, view IG details (/view-ig/<id>), including: After processing, view IG details (/view-ig/<id>), including:
Resource types and profiles. Resource types and profiles.
Must-support elements and examples. Must-support elements and examples.
Profile relationships (compliesWithProfile, imposeProfile) if enabled (DISPLAY_PROFILE_RELATIONSHIPS). Profile relationships (compliesWithProfile, imposeProfile) if enabled (DISPLAY_PROFILE_RELATIONSHIPS).
Interactive StructureDefinition viewer (Differential, Snapshot, Must Support, Key Elements, Constraints, Terminology, Search Params). Interactive StructureDefinition viewer (Differential, Snapshot, Must Support, Key Elements, Constraints, Terminology, Search Params).
Validate FHIR Resources/Bundles Validate FHIR Resources/Bundles
Navigate to Validate FHIR Sample (/validate-sample). Navigate to Validate FHIR Sample (/validate-sample).
Select a package (e.g., hl7.fhir.au.core#1.1.0-preview). Select a package (e.g., hl7.fhir.au.core#1.1.0-preview).
Choose Single Resource or Bundle mode. Choose Single Resource or Bundle mode.
Paste or upload FHIR JSON/XML (e.g., a Patient resource). Paste or upload FHIR JSON/XML (e.g., a Patient resource).
Submit to view validation errors/warnings. Note: Alpha feature; report issues to GitHub (remove PHI).
Submit to view validation errors/warnings.
Note: Alpha feature; report issues to GitHub (remove PHI).
Push IGs to a FHIR Server Push IGs to a FHIR Server
Go to Push IGs (/push-igs). Go to Push IGs (/push-igs).
Select a downloaded package. Select a downloaded package.
Enter the Target FHIR Server URL. Enter the Target FHIR Server URL.
Configure Authentication (None, Bearer Token). Configure Authentication (None, Bearer Token).
Choose options: Include Dependencies, Force Upload (skips comparison check), Dry Run, Verbose Log. Choose options: Include Dependencies, Force Upload (skips comparison check), Dry Run, Verbose Log.
Optionally filter by Resource Types (comma-separated) or Skip Specific Files (paths within package, comma/newline separated). Optionally filter by Resource Types (comma-separated) or Skip Specific Files (paths within package, comma/newline separated).
Click Push to FHIR Server to upload resources. Canonical resources are checked before upload. Identical resources are skipped unless Force Upload is checked. Click Push to FHIR Server to upload resources. Canonical resources are checked before upload. Identical resources are skipped unless Force Upload is checked.
Monitor progress in the live console. Monitor progress in the live console.
Upload Test Data Upload Test Data
Navigate to Upload Test Data (/upload-test-data). Navigate to Upload Test Data (/upload-test-data).
Enter the Target FHIR Server URL. Enter the Target FHIR Server URL.
Configure Authentication (None, Bearer Token). Configure Authentication (None, Bearer Token).
Select one or more .json, .xml files, or a single .zip file containing test resources. Select one or more .json, .xml files, or a single .zip file containing test resources.
Optionally check Validate Resources Before Upload? and select a Validation Profile Package. Optionally check Validate Resources Before Upload? and select a Validation Profile Package.
Choose Upload Mode: Choose Upload Mode:
Individual Resources: Uploads each resource one by one in dependency order. Individual Resources: Uploads each resource one by one in dependency order.
Transaction Bundle: Uploads all resources in a single transaction. Transaction Bundle: Uploads all resources in a single transaction.
Optionally check Use Conditional Upload (Individual Mode Only)? to use If-Match headers for updates. Optionally check Use Conditional Upload (Individual Mode Only)? to use If-Match headers for updates.
Choose Error Handling: Choose Error Handling:
Stop on First Error: Halts the process if any validation or upload fails. Stop on First Error: Halts the process if any validation or upload fails.
Continue on Error: Reports errors but attempts to process/upload remaining resources. Continue on Error: Reports errors but attempts to process/upload remaining resources.
Click Upload and Process. The tool parses files, optionally validates, analyzes dependencies, topologically sorts resources, and uploads them according to selected options. Click Upload and Process. The tool parses files, optionally validates, analyzes dependencies, topologically sorts resources, and uploads them according to selected options.
Monitor progress in the streaming log output. Monitor progress in the streaming log output.
Convert FHIR to FSH Convert FHIR to FSH
Navigate to FSH Converter (/fsh-converter). Navigate to FSH Converter (/fsh-converter).
Optionally select a package for context (e.g., hl7.fhir.au.core#1.1.0-preview). Optionally select a package for context (e.g., hl7.fhir.au.core#1.1.0-preview).
Choose input mode: Choose input mode:
Upload File: Upload a FHIR JSON/XML file. Upload File: Upload a FHIR JSON/XML file.
Paste Text: Paste FHIR JSON/XML content. Paste Text: Paste FHIR JSON/XML content.
Configure options: Configure options:
Output Style: file-per-definition, group-by-fsh-type, group-by-profile, single-file. Output Style: file-per-definition, group-by-fsh-type, group-by-profile, single-file.
Log Level: error, warn, info, debug. Log Level: error, warn, info, debug.
FHIR Version: R4, R4B, R5, or auto-detect. FHIR Version: R4, R4B, R5, or auto-detect.
Fishing Trip: Enable round-trip validation with SUSHI, generating a comparison report. Fishing Trip: Enable round-trip validation with SUSHI, generating a comparison report.
Dependencies: Specify additional packages (e.g., hl7.fhir.us.core@6.1.0, one per line). Dependencies: Specify additional packages (e.g., hl7.fhir.us.core@6.1.0, one per line).
Indent Rules: Enable context path indentation for readable FSH. Indent Rules: Enable context path indentation for readable FSH.
Meta Profile: Choose only-one, first, or none for meta.profile handling. Meta Profile: Choose only-one, first, or none for meta.profile handling.
Alias File: Upload an FSH file with aliases (e.g., $MyAlias = http://example.org). Alias File: Upload an FSH file with aliases (e.g., $MyAlias = http://example.org).
No Alias: Disable automatic alias generation. No Alias: Disable automatic alias generation.
Click Convert to FSH to generate and display FSH output, with a waiting spinner (light/dark theme) during processing. Click Convert to FSH to generate and display FSH output, with a waiting spinner (light/dark theme) during processing.
If Fishing Trip is enabled, view the comparison report via the "Click here for SUSHI Validation" badge button. If Fishing Trip is enabled, view the comparison report via the "Click here for SUSHI Validation" badge button.
Download the result as a .fsh file. Download the result as a .fsh file.
Retrieve and Split Bundles
Navigate to Retrieve/Split Data (/retrieve-split-data).
Retrieve Bundles from Server:
Enter the FHIR Server URL (defaults to the proxy if empty).
Select one or more Resource Types to retrieve (e.g., Patient, Observation).
Optionally check Fetch Referenced Resources.
If checked, further optionally check Fetch Full Reference Bundles to retrieve entire bundles for each referenced type (e.g., all Patients if a Patient is referenced) instead of individual resources by ID.
Click Retrieve Bundles.
Monitor progress in the streaming log. A ZIP file containing the retrieved bundles/resources will be prepared for download.
Split Uploaded Bundles:
Upload a ZIP file containing FHIR bundles (JSON format).
Click Split Bundles.
A ZIP file containing individual resources extracted from the bundles will be prepared for download.
Explore FHIR Operations Explore FHIR Operations
Navigate to FHIR UI Operations (/fhir-ui-operations). Navigate to FHIR UI Operations (/fhir-ui-operations).
Toggle between local HAPI (/fhir) or a custom FHIR server. Toggle between local HAPI (/fhir) or a custom FHIR server.
Click Fetch Metadata to load the servers CapabilityStatement. Click Fetch Metadata to load the servers CapabilityStatement.
Select a resource type (e.g., Patient, Observation) or System to view operations: Select a resource type (e.g., Patient, Observation) or System to view operations:
System operations: GET /metadata, POST /, GET /_history, GET/POST /$diff, POST /$reindex, POST /$expunge, etc. System operations: GET /metadata, POST /, GET /_history, GET/POST /$diff, POST /$reindex, POST /$expunge, etc.
Resource operations: GET Patient/:id, POST Observation/_search, etc. Resource operations: GET Patient/:id, POST Observation/_search, etc.
Use Try it out to input parameters or request bodies, then Execute to view results in JSON, XML, or narrative formats. Use Try it out to input parameters or request bodies, then Execute to view results in JSON, XML, or narrative formats.
### Configure Embedded HAPI FHIR Server (Standalone Mode)
For users running the **Standalone version**, which includes an embedded HAPI FHIR server.
1. Navigate to **Configure HAPI FHIR** (`/config-hapi`).
2. The page displays the content of the HAPI FHIR server's `application.yaml` file.
3. You can edit the configuration directly in the text area.
* *Caution: Incorrect modifications can break the HAPI FHIR server.*
4. Click **Save Configuration** to apply your changes to the `application.yaml` file.
5. Click **Restart Tomcat** to restart the HAPI FHIR server and load the new configuration. The restart process may take a few moments.
API Usage API Usage
Import IG Import IG
Bash
curl -X POST http://localhost:5000/api/import-ig \ curl -X POST http://localhost:5000/api/import-ig \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \ -H "X-API-Key: your-api-key" \
-d '{"package_name": "hl7.fhir.au.core", "version": "1.1.0-preview", "dependency_mode": "recursive"}' -d '{"package_name": "hl7.fhir.au.core", "version": "1.1.0-preview", "dependency_mode": "recursive"}'
Returns complies_with_profiles, imposed_profiles, and duplicate_packages_present info. Returns complies_with_profiles, imposed_profiles, and duplicate_packages_present info.
### Refresh Package Cache (Background Task)
```bash
curl -X POST http://localhost:5000/api/refresh-cache-task \
-H "X-API-Key: your-api-key"
Push IG Push IG
Bash
curl -X POST http://localhost:5000/api/push-ig \ curl -X POST http://localhost:5000/api/push-ig \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Accept: application/x-ndjson" \ -H "Accept: application/x-ndjson" \
@ -393,10 +404,11 @@ curl -X POST http://localhost:5000/api/push-ig \
"verbose": false, "verbose": false,
"auth_type": "none" "auth_type": "none"
}' }'
Returns a streaming NDJSON response with progress and final summary. Returns a streaming NDJSON response with progress and final summary.
Upload Test Data Upload Test Data
Bash
curl -X POST http://localhost:5000/api/upload-test-data \ curl -X POST http://localhost:5000/api/upload-test-data \
-H "X-API-Key: your-api-key" \ -H "X-API-Key: your-api-key" \
-H "Accept: application/x-ndjson" \ -H "Accept: application/x-ndjson" \
@ -410,10 +422,29 @@ curl -X POST http://localhost:5000/api/upload-test-data \
-F "use_conditional_uploads=true" \ -F "use_conditional_uploads=true" \
-F "test_data_files=@/path/to/your/patient.json" \ -F "test_data_files=@/path/to/your/patient.json" \
-F "test_data_files=@/path/to/your/observations.zip" -F "test_data_files=@/path/to/your/observations.zip"
Returns a streaming NDJSON response with progress and final summary. Uses multipart/form-data for file uploads.
Returns a streaming NDJSON response with progress and final summary. Retrieve Bundles
Bash
Uses multipart/form-data for file uploads. curl -X POST http://localhost:5000/api/retrieve-bundles \
-H "X-API-Key: your-api-key" \
-H "Accept: application/x-ndjson" \
-F "fhir_server_url=http://your-fhir-server/fhir" \
-F "resources=Patient" \
-F "resources=Observation" \
-F "validate_references=true" \
-F "fetch_reference_bundles=false"
Returns a streaming NDJSON response with progress. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive (e.g., /tmp/retrieved_bundles_datetime.zip).
Split Bundles
Bash
curl -X POST http://localhost:5000/api/split-bundles \
-H "X-API-Key: your-api-key" \
-H "Accept: application/x-ndjson" \
-F "split_bundle_zip_path=@/path/to/your/bundles.zip"
Returns a streaming NDJSON response. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive of split resources.
Validate Resource/Bundle Validate Resource/Bundle
Not yet exposed via API; use the UI at /validate-sample. Not yet exposed via API; use the UI at /validate-sample.
@ -422,181 +453,132 @@ Configuration Options
Located in app.py: Located in app.py:
VALIDATE_IMPOSED_PROFILES: (Default: True) Validates resources against imposed profiles during push. VALIDATE_IMPOSED_PROFILES: (Default: True) Validates resources against imposed profiles during push.
DISPLAY_PROFILE_RELATIONSHIPS: (Default: True) Shows compliesWithProfile and imposeProfile in the UI. DISPLAY_PROFILE_RELATIONSHIPS: (Default: True) Shows compliesWithProfile and imposeProfile in the UI.
FHIR_PACKAGES_DIR: (Default: /app/instance/fhir_packages) Stores .tgz packages and metadata. FHIR_PACKAGES_DIR: (Default: /app/instance/fhir_packages) Stores .tgz packages and metadata.
UPLOAD_FOLDER: (Default: /app/static/uploads) Stores GoFSH output files and FSH comparison reports. UPLOAD_FOLDER: (Default: /app/static/uploads) Stores GoFSH output files and FSH comparison reports.
SECRET_KEY: Required for CSRF protection and sessions. Set via environment variable or directly. SECRET_KEY: Required for CSRF protection and sessions. Set via environment variable or directly.
API_KEY: Required for API authentication. Set via environment variable or directly. API_KEY: Required for API authentication. Set via environment variable or directly.
MAX_CONTENT_LENGTH: (Default: Flask default) Max size for HTTP request body (e.g., 16 * 1024 * 1024 for 16MB). Important for large uploads. MAX_CONTENT_LENGTH: (Default: Flask default) Max size for HTTP request body (e.g., 16 * 1024 * 1024 for 16MB). Important for large uploads.
MAX_FORM_PARTS: (Default: Werkzeug default, often 1000) Default max number of form parts. Overridden for /api/upload-test-data by CustomFormDataParser. MAX_FORM_PARTS: (Default: Werkzeug default, often 1000) Default max number of form parts. Overridden for /api/upload-test-data by CustomFormDataParser.
### Get HAPI FHIR Configuration (Standalone Mode)
```bash
curl -X GET http://localhost:5000/api/config \
-H "X-API-Key: your-api-key"
Save HAPI FHIR Configuration:
curl -X POST http://localhost:5000/api/config \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{"your_yaml_key": "your_value", ...}' # Send the full YAML content as JSON
Restart HAPI FHIR Server:
curl -X POST http://localhost:5000/api/restart-tomcat \
-H "X-API-Key: your-api-key"
Testing Testing
The project includes a test suite covering UI, API, database, file operations, and security. The project includes a test suite covering UI, API, database, file operations, and security.
Test Prerequisites: Test Prerequisites:
pytest: For running tests. pytest: For running tests.
pytest-mock: For mocking dependencies. Install: pip install pytest pytest-mock
pytest-mock: For mocking dependencies.
Install: pip install pytest pytest-mock
Running Tests: Running Tests:
Bash
cd <project folder> cd <project folder>
pytest tests/test_app.py -v pytest tests/test_app.py -v
Test Coverage: Test Coverage:
UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data. UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data, Retrieve/Split Data.
API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data, POST /api/retrieve-bundles, POST /api/split-bundles.
API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data.
Database: IG processing, unloading, viewing. Database: IG processing, unloading, viewing.
File Operations: Package processing, deletion, FSH output, ZIP handling.
File Operations: Package processing, deletion, FSH output.
Security: CSRF protection, flash messages, secret key. Security: CSRF protection, flash messages, secret key.
FSH Converter: Form submission, file/text input, GoFSH execution, Fishing Trip comparison. FSH Converter: Form submission, file/text input, GoFSH execution, Fishing Trip comparison.
Upload Test Data: Parsing, dependency graph, sorting, upload modes, validation, conditional uploads. Upload Test Data: Parsing, dependency graph, sorting, upload modes, validation, conditional uploads.
Development Notes Development Notes
Background Background
The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, and dependency-aware test data uploading, making it a versatile platform for FHIR developers. The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, dependency-aware test data uploading, and bundle retrieval/splitting, making it a versatile platform for FHIR developers.
Technical Decisions Technical Decisions
Flask: Lightweight and flexible for web development. Flask: Lightweight and flexible for web development.
SQLite: Simple for development; consider PostgreSQL for production. SQLite: Simple for development; consider PostgreSQL for production.
Bootstrap 5.3.3: Responsive UI with custom styling. Bootstrap 5.3.3: Responsive UI with custom styling.
Lottie-Web: Renders themed animations for FSH conversion waiting spinner. Lottie-Web: Renders themed animations for FSH conversion waiting spinner.
GoFSH/SUSHI: Integrated via Node.js for advanced FSH conversion and round-trip validation. GoFSH/SUSHI: Integrated via Node.js for advanced FSH conversion and round-trip validation.
Docker: Ensures consistent deployment with Flask and HAPI FHIR. Docker: Ensures consistent deployment with Flask and HAPI FHIR.
Flexible Versioning: Supports non-standard IG versions (e.g., -preview, -ballot). Flexible Versioning: Supports non-standard IG versions (e.g., -preview, -ballot).
Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH, Retrieve Bundles).
Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH).
Validation: Alpha feature with ongoing FHIRPath improvements. Validation: Alpha feature with ongoing FHIRPath improvements.
Dependency Management: Uses topological sort for Upload Test Data feature. Dependency Management: Uses topological sort for Upload Test Data feature.
Form Parsing: Uses custom Werkzeug parser for Upload Test Data to handle large numbers of files. Form Parsing: Uses custom Werkzeug parser for Upload Test Data to handle large numbers of files.
Recent Updates Recent Updates
* Enhanced package search page with caching, detailed views (dependencies, dependents, version history), and background cache refresh.
Upload Test Data Enhancements (April 2025): Upload Test Data Enhancements (April 2025):
Added optional Pre-Upload Validation against selected IG profiles. Added optional Pre-Upload Validation against selected IG profiles.
Added optional Conditional Uploads (GET + POST/PUT w/ If-Match) for individual mode. Added optional Conditional Uploads (GET + POST/PUT w/ If-Match) for individual mode.
Implemented robust XML parsing using fhir.resources library (when available). Implemented robust XML parsing using fhir.resources library (when available).
Fixed 413 Request Entity Too Large errors for large file counts using a custom Werkzeug FormDataParser. Fixed 413 Request Entity Too Large errors for large file counts using a custom Werkzeug FormDataParser.
Path: templates/upload_test_data.html, app.py, services.py, forms.py. Path: templates/upload_test_data.html, app.py, services.py, forms.py.
Push IG Enhancements (April 2025): Push IG Enhancements (April 2025):
Added semantic comparison to skip uploading identical resources. Added semantic comparison to skip uploading identical resources.
Added "Force Upload" option to bypass comparison. Added "Force Upload" option to bypass comparison.
Improved handling of canonical resources (search before PUT/POST). Improved handling of canonical resources (search before PUT/POST).
Added filtering by specific files to skip during push. Added filtering by specific files to skip during push.
More detailed summary report in stream response. More detailed summary report in stream response.
Path: templates/cp_push_igs.html, app.py, services.py. Path: templates/cp_push_igs.html, app.py, services.py.
Waiting Spinner for FSH Converter (April 2025): Waiting Spinner for FSH Converter (April 2025):
Added a themed (light/dark) Lottie animation spinner during FSH execution. Added a themed (light/dark) Lottie animation spinner during FSH execution.
Path: templates/fsh_converter.html, static/animations/, static/js/lottie-web.min.js. Path: templates/fsh_converter.html, static/animations/, static/js/lottie-web.min.js.
Advanced FSH Converter (April 2025): Advanced FSH Converter (April 2025):
Added support for GoFSH advanced options: --fshing-trip, --dependency, --indent, --meta-profile, --alias-file, --no-alias. Added support for GoFSH advanced options: --fshing-trip, --dependency, --indent, --meta-profile, --alias-file, --no-alias.
Displays Fishing Trip comparison reports. Displays Fishing Trip comparison reports.
Path: templates/fsh_converter.html, app.py, services.py, forms.py. Path: templates/fsh_converter.html, app.py, services.py, forms.py.
(New) Retrieve and Split Data (May 2025):
(Keep older updates listed here if desired) Added UI and API for retrieving bundles from a FHIR server by resource type.
Added options to fetch referenced resources (individually or as full type bundles).
Added functionality to split uploaded ZIP files of bundles into individual resources.
Streaming log for retrieval and ZIP download for results.
Paths: templates/retrieve_split_data.html, app.py, services.py, forms.py.
Known Issues and Workarounds Known Issues and Workarounds
Favicon 404: Clear browser cache or verify /app/static/favicon.ico. Favicon 404: Clear browser cache or verify /app/static/favicon.ico.
CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms. CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
Import Fails: Check package name/version and connectivity. Import Fails: Check package name/version and connectivity.
Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI). Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing. Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
Permissions: Ensure instance/ and static/uploads/ are writable. Permissions: Ensure instance/ and static/uploads/ are writable.
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation. GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation.
Upload Test Data XML Parsing: Relies on fhir.resources library for full validation; basic parsing used as fallback. Complex XML structures might not be fully analyzed for dependencies with basic parsing. Prefer JSON for reliable dependency analysis. Upload Test Data XML Parsing: Relies on fhir.resources library for full validation; basic parsing used as fallback. Complex XML structures might not be fully analyzed for dependencies with basic parsing. Prefer JSON for reliable dependency analysis.
413 Request Entity Too Large: Primarily handled by CustomFormDataParser for /api/upload-test-data. Check the parser's max_form_parts limit if still occurring. MAX_CONTENT_LENGTH in app.py controls overall size. Reverse proxy limits (client_max_body_size in Nginx) might also apply. 413 Request Entity Too Large: Primarily handled by CustomFormDataParser for /api/upload-test-data. Check the parser's max_form_parts limit if still occurring. MAX_CONTENT_LENGTH in app.py controls overall size. Reverse proxy limits (client_max_body_size in Nginx) might also apply.
Future Improvements Future Improvements
Upload Test Data: Improve XML parsing further (direct XML->fhir.resource object if possible), add visual progress bar, add upload order preview, implement transaction bundle size splitting, add 'Clear Target Server' option (with confirmation). Upload Test Data: Improve XML parsing further (direct XML->fhir.resource object if possible), add visual progress bar, add upload order preview, implement transaction bundle size splitting, add 'Clear Target Server' option (with confirmation).
Validation: Enhance FHIRPath for complex constraints; add API endpoint. Validation: Enhance FHIRPath for complex constraints; add API endpoint.
Sorting: Sort IG versions in /view-igs (e.g., ascending). Sorting: Sort IG versions in /view-igs (e.g., ascending).
Duplicate Resolution: Options to keep latest version or merge resources. Duplicate Resolution: Options to keep latest version or merge resources.
Production Database: Support PostgreSQL. Production Database: Support PostgreSQL.
Error Reporting: Detailed validation error paths in the UI. Error Reporting: Detailed validation error paths in the UI.
FSH Enhancements: Add API endpoint for FSH conversion; support inline instance construction. FSH Enhancements: Add API endpoint for FSH conversion; support inline instance construction.
FHIR Operations: Add complex parameter support (e.g., /$diff with left/right). FHIR Operations: Add complex parameter support (e.g., /$diff with left/right).
Retrieve/Split Data: Add option to filter resources during retrieval (e.g., by date, specific IDs).
Completed Items Completed Items
Testing suite with basic coverage. Testing suite with basic coverage.
API endpoints for POST /api/import-ig and POST /api/push-ig. API endpoints for POST /api/import-ig and POST /api/push-ig.
Flexible versioning (-preview, -ballot). Flexible versioning (-preview, -ballot).
CSRF fixes for forms. CSRF fixes for forms.
Resource validation UI (alpha). Resource validation UI (alpha).
FSH Converter with advanced GoFSH features and waiting spinner. FSH Converter with advanced GoFSH features and waiting spinner.
Push IG enhancements (force upload, semantic comparison, canonical handling, skip files). Push IG enhancements (force upload, semantic comparison, canonical handling, skip files).
Upload Test Data feature with dependency sorting, multiple upload modes, pre-upload validation, conditional uploads, robust XML parsing, and fix for large file counts. Upload Test Data feature with dependency sorting, multiple upload modes, pre-upload validation, conditional uploads, robust XML parsing, and fix for large file counts.
Retrieve and Split Data functionality with reference fetching and ZIP download.
Far-Distant Improvements Far-Distant Improvements
Cache Service: Use Redis for IG metadata caching. Cache Service: Use Redis for IG metadata caching.
Database Optimization: Composite index on ProcessedIg.package_name and ProcessedIg.version. Database Optimization: Composite index on ProcessedIg.package_name and ProcessedIg.version.
Directory Structure Directory Structure
FHIRFLARE-IG-Toolkit/ FHIRFLARE-IG-Toolkit/
├── app.py # Main Flask application ├── app.py # Main Flask application
@ -608,7 +590,7 @@ FHIRFLARE-IG-Toolkit/
├── README.md # Project documentation ├── README.md # Project documentation
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── Run.bat # Windows script for running Docker ├── Run.bat # Windows script for running Docker
├── services.py # Logic for IG import, processing, validation, pushing, FSH conversion, test data upload ├── services.py # Logic for IG import, processing, validation, pushing, FSH conversion, test data upload, retrieve/split
├── supervisord.conf # Supervisor configuration ├── supervisord.conf # Supervisor configuration
├── hapi-fhir-Setup/ ├── hapi-fhir-Setup/
│ ├── README.md # HAPI FHIR setup instructions │ ├── README.md # HAPI FHIR setup instructions
@ -649,38 +631,31 @@ FHIRFLARE-IG-Toolkit/
│ ├── fsh_converter.html # UI for FSH conversion │ ├── fsh_converter.html # UI for FSH conversion
│ ├── import_ig.html # UI for importing IGs │ ├── import_ig.html # UI for importing IGs
│ ├── index.html # Homepage │ ├── index.html # Homepage
│ ├── retrieve_split_data.html # UI for Retrieve and Split Data
│ ├── upload_test_data.html # UI for Uploading Test Data │ ├── upload_test_data.html # UI for Uploading Test Data
│ ├── validate_sample.html # UI for validating resources/bundles │ ├── validate_sample.html # UI for validating resources/bundles
│ ├── config_hapi.html # UI for HAPI FHIR Configuration
│ └── _form_helpers.html # Form helper macros │ └── _form_helpers.html # Form helper macros
├── tests/ ├── tests/
│ └── test_app.py # Test suite │ └── test_app.py # Test suite
└── hapi-fhir-jpaserver/ # HAPI FHIR server resources (if Standalone) └── hapi-fhir-jpaserver/ # HAPI FHIR server resources (if Standalone)
Contributing
Fork the repository.
Create a feature branch (git checkout -b feature/your-feature).
Commit changes (git commit -m "Add your feature").
Push to your branch (git push origin feature/your-feature).
Open a Pull Request.
Ensure code follows PEP 8 and includes tests in tests/test_app.py.
## Contributing Troubleshooting
Favicon 404: Clear browser cache or verify /app/static/favicon.ico: docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico
* Fork the repository. CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
* Create a feature branch (`git checkout -b feature/your-feature`). Import Fails: Check package name/version and connectivity.
* Commit changes (`git commit -m "Add your feature"`). Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
* Push to your branch (`git push origin feature/your-feature`). Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
* Open a Pull Request. Permissions: Ensure instance/ and static/uploads/ are writable: chmod -R 777 instance static/uploads logs
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation: docker exec -it <container_name> sushi --version
Ensure code follows PEP 8 and includes tests in `tests/test_app.py`. 413 Request Entity Too Large: Increase MAX_CONTENT_LENGTH and MAX_FORM_PARTS in app.py. If using a reverse proxy (e.g., Nginx), increase its client_max_body_size setting as well. Ensure the application/container is fully restarted/rebuilt.
License
## Troubleshooting Licensed under the Apache 2.0 License. See LICENSE.md for details.
* **Favicon 404:** Clear browser cache or verify `/app/static/favicon.ico`:
`docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico`
* **CSRF Errors:** Set `FLASK_SECRET_KEY` and ensure `{{ form.hidden_tag() }}` in forms.
* **Import Fails:** Check package name/version and connectivity.
* **Validation Accuracy:** Alpha feature; report issues to GitHub (remove PHI).
* **Package Parsing:** Non-standard `.tgz` filenames may parse incorrectly. Fallback uses name-only parsing.
* **Permissions:** Ensure `instance/` and `static/uploads/` are writable:
`chmod -R 777 instance static/uploads logs`
* **GoFSH/SUSHI Errors:** Check `./logs/flask_err.log` for `ERROR:services:GoFSH failed`. Ensure valid FHIR inputs and SUSHI installation:
`docker exec -it <container_name> sushi --version`
* **413 Request Entity Too Large:** Increase `MAX_CONTENT_LENGTH` and `MAX_FORM_PARTS` in `app.py`. If using a reverse proxy (e.g., Nginx), increase its `client_max_body_size` setting as well. Ensure the application/container is fully restarted/rebuilt.
## License
Licensed under the Apache 2.0 License. See `LICENSE.md` for details.

2470
app.py

File diff suppressed because it is too large Load Diff

View File

@ -16,5 +16,7 @@ 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
- APP_BASE_URL=http://localhost:5000
- HAPI_FHIR_URL=http://localhost:8080/fhir
command: supervisord -c /etc/supervisord.conf command: supervisord -c /etc/supervisord.conf

163
forms.py
View File

@ -1,16 +1,63 @@
# 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, PasswordField
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 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 os
logger = logging.getLogger(__name__) # Setup logger if needed elsewhere logger = logging.getLogger(__name__)
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'})
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
('none', 'None'),
('bearerToken', 'Bearer Token'),
('basicAuth', 'Basic Authentication')
], default='none', validators=[Optional()])
auth_token = StringField('Bearer Token', validators=[Optional()],
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
basic_auth_username = StringField('Username', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Username'})
basic_auth_password = PasswordField('Password', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Password'})
validate_references = BooleanField('Fetch Referenced Resources', default=False,
description="If checked, fetches resources referenced by the initial bundles.")
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'})
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):
if not super().validate(extra_validators):
return False
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
if self.auth_type.data == 'bearerToken' and self.submit_retrieve.data and not self.auth_token.data:
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
return False
if self.auth_type.data == 'basicAuth' and self.submit_retrieve.data:
if not self.basic_auth_username.data:
self.basic_auth_username.errors.append('Username is required for Basic Authentication.')
return False
if not self.basic_auth_password.data:
self.basic_auth_password.errors.append('Password is required for Basic Authentication.')
return False
if self.split_bundle_zip.data:
if not self.split_bundle_zip.data.filename.lower().endswith('.zip'):
self.split_bundle_zip.errors.append('File must be a ZIP file.')
return False
return True
# Existing forms (IgImportForm, ValidationForm) remain unchanged
class IgImportForm(FlaskForm): class IgImportForm(FlaskForm):
"""Form for importing Implementation Guides.""" """Form for importing Implementation Guides."""
package_name = StringField('Package Name', validators=[ package_name = StringField('Package Name', validators=[
@ -39,7 +86,6 @@ class ValidationForm(FlaskForm):
], default='single') ], default='single')
sample_input = TextAreaField('Sample Input', validators=[ sample_input = TextAreaField('Sample Input', validators=[
DataRequired(), DataRequired(),
# Removed lambda validator for simplicity, can be added back if needed
]) ])
submit = SubmitField('Validate') submit = SubmitField('Validate')
@ -64,7 +110,7 @@ class FSHConverterForm(FlaskForm):
('info', 'Info'), ('info', 'Info'),
('debug', 'Debug') ('debug', 'Debug')
], validators=[DataRequired()]) ], validators=[DataRequired()])
fhir_version = SelectField('FHIR Version', choices=[ # Corrected label fhir_version = SelectField('FHIR Version', choices=[
('', 'Auto-detect'), ('', 'Auto-detect'),
('4.0.1', 'R4'), ('4.0.1', 'R4'),
('4.3.0', 'R4B'), ('4.3.0', 'R4B'),
@ -83,116 +129,125 @@ class FSHConverterForm(FlaskForm):
submit = SubmitField('Convert to FSH') submit = SubmitField('Convert to FSH')
def validate(self, extra_validators=None): def validate(self, extra_validators=None):
"""Custom validation for FSH Converter Form."""
# Run default validators first
if not super().validate(extra_validators): if not super().validate(extra_validators):
return False return False
# Check file/text input based on mode
# Need to check request.files for file uploads as self.fhir_file.data might be None during initial POST validation
has_file_in_request = request and request.files and self.fhir_file.name in request.files and request.files[self.fhir_file.name].filename != '' has_file_in_request = request and request.files and self.fhir_file.name in request.files and request.files[self.fhir_file.name].filename != ''
if self.input_mode.data == 'file' and not has_file_in_request: if self.input_mode.data == 'file' and not has_file_in_request:
# If it's not in request.files, check if data is already populated (e.g., on re-render after error)
if not self.fhir_file.data: if not self.fhir_file.data:
self.fhir_file.errors.append('File is required when input mode is Upload File.') self.fhir_file.errors.append('File is required when input mode is Upload File.')
return False return False
if self.input_mode.data == 'text' and not self.fhir_text.data: if self.input_mode.data == 'text' and not self.fhir_text.data:
self.fhir_text.errors.append('Text input is required when input mode is Paste Text.') self.fhir_text.errors.append('Text input is required when input mode is Paste Text.')
return False return False
# Validate text input format
if self.input_mode.data == 'text' and self.fhir_text.data: if self.input_mode.data == 'text' and self.fhir_text.data:
try: try:
content = self.fhir_text.data.strip() content = self.fhir_text.data.strip()
if not content: # Empty text is technically valid but maybe not useful if not content: pass
pass # Allow empty text for now elif content.startswith('{'): json.loads(content)
elif content.startswith('{'): elif content.startswith('<'): ET.fromstring(content)
json.loads(content)
elif content.startswith('<'):
ET.fromstring(content) # Basic XML check
else: else:
# If content exists but isn't JSON or XML, it's an error
self.fhir_text.errors.append('Text input must be valid JSON or XML.') self.fhir_text.errors.append('Text input must be valid JSON or XML.')
return False return False
except (json.JSONDecodeError, ET.ParseError): except (json.JSONDecodeError, ET.ParseError):
self.fhir_text.errors.append('Invalid JSON or XML format.') self.fhir_text.errors.append('Invalid JSON or XML format.')
return False return False
# Validate dependency format
if self.dependencies.data: if self.dependencies.data:
for dep in self.dependencies.data.splitlines(): for dep in self.dependencies.data.splitlines():
dep = dep.strip() dep = dep.strip()
# Allow versions like 'current', 'dev', etc. but require package@version format
if dep and not re.match(r'^[a-zA-Z0-9\-\.]+@[a-zA-Z0-9\.\-]+$', dep): if dep and not re.match(r'^[a-zA-Z0-9\-\.]+@[a-zA-Z0-9\.\-]+$', dep):
self.dependencies.errors.append(f'Invalid dependency format: "{dep}". Use package@version (e.g., hl7.fhir.us.core@6.1.0).') self.dependencies.errors.append(f'Invalid dependency format: "{dep}". Use package@version (e.g., hl7.fhir.us.core@6.1.0).')
return False return False
# Validate alias file extension (optional, basic check)
# Check request.files for alias file as well
has_alias_file_in_request = request and request.files and self.alias_file.name in request.files and request.files[self.alias_file.name].filename != '' has_alias_file_in_request = request and request.files and self.alias_file.name in request.files and request.files[self.alias_file.name].filename != ''
alias_file_data = self.alias_file.data or (request.files.get(self.alias_file.name) if request else None) alias_file_data = self.alias_file.data or (request.files.get(self.alias_file.name) if request else None)
if alias_file_data and alias_file_data.filename: if alias_file_data and alias_file_data.filename:
if not alias_file_data.filename.lower().endswith('.fsh'): if not alias_file_data.filename.lower().endswith('.fsh'):
self.alias_file.errors.append('Alias file should have a .fsh extension.') self.alias_file.errors.append('Alias file should have a .fsh extension.')
# return False # Might be too strict, maybe just warn?
return True return True
class TestDataUploadForm(FlaskForm): class TestDataUploadForm(FlaskForm):
"""Form for uploading FHIR test data.""" """Form for uploading FHIR test data."""
fhir_server_url = StringField('Target FHIR Server URL', validators=[DataRequired(), URL()], fhir_server_url = StringField('Target FHIR Server URL', validators=[DataRequired(), URL()],
render_kw={'placeholder': 'e.g., http://localhost:8080/fhir'}) render_kw={'placeholder': 'e.g., http://localhost:8080/fhir'})
auth_type = SelectField('Authentication Type', choices=[ auth_type = SelectField('Authentication Type', choices=[
('none', 'None'), ('none', 'None'),
('bearerToken', 'Bearer Token') ('bearerToken', 'Bearer Token'),
('basic', 'Basic Authentication')
], default='none') ], default='none')
auth_token = StringField('Bearer Token', validators=[Optional()], auth_token = StringField('Bearer Token', validators=[Optional()],
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'}) render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
username = StringField('Username', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Username'})
password = PasswordField('Password', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Password'})
test_data_file = FileField('Select Test Data File(s)', validators=[InputRequired("Please select at least one file.")], test_data_file = FileField('Select Test Data File(s)', validators=[InputRequired("Please select at least one file.")],
render_kw={'multiple': True, 'accept': '.json,.xml,.zip'}) render_kw={'multiple': True, 'accept': '.json,.xml,.zip'})
validate_before_upload = BooleanField('Validate Resources Before Upload?', default=False, validate_before_upload = BooleanField('Validate Resources Before Upload?', default=False,
description="Validate resources against selected package profile before uploading.") description="Validate resources against selected package profile before uploading.")
validation_package_id = SelectField('Validation Profile Package (Optional)', validation_package_id = SelectField('Validation Profile Package (Optional)',
choices=[('', '-- Select Package for Validation --')], choices=[('', '-- Select Package for Validation --')],
validators=[Optional()], validators=[Optional()],
description="Select the processed IG package to use for validation.") description="Select the processed IG package to use for validation.")
upload_mode = SelectField('Upload Mode', choices=[ upload_mode = SelectField('Upload Mode', choices=[
('individual', 'Individual Resources'), # Simplified label ('individual', 'Individual Resources'),
('transaction', 'Transaction Bundle') # Simplified label ('transaction', 'Transaction Bundle')
], default='individual') ], default='individual')
# --- NEW FIELD for Conditional Upload ---
use_conditional_uploads = BooleanField('Use Conditional Upload (Individual Mode Only)?', default=True, use_conditional_uploads = BooleanField('Use Conditional Upload (Individual Mode Only)?', default=True,
description="If checked, checks resource existence (GET) and uses If-Match (PUT) or creates (PUT). If unchecked, uses simple PUT for all.") description="If checked, checks resource existence (GET) and uses If-Match (PUT) or creates (PUT). If unchecked, uses simple PUT for all.")
# --- END NEW FIELD ---
error_handling = SelectField('Error Handling', choices=[ error_handling = SelectField('Error Handling', choices=[
('stop', 'Stop on First Error'), ('stop', 'Stop on First Error'),
('continue', 'Continue on Error') ('continue', 'Continue on Error')
], default='stop') ], default='stop')
submit = SubmitField('Upload and Process') submit = SubmitField('Upload and Process')
def validate(self, extra_validators=None): def validate(self, extra_validators=None):
"""Custom validation for Test Data Upload Form.""" if not super().validate(extra_validators):
if not super().validate(extra_validators): return False return False
if self.validate_before_upload.data and not self.validation_package_id.data: if self.validate_before_upload.data and not self.validation_package_id.data:
self.validation_package_id.errors.append('Please select a package to validate against when pre-upload validation is enabled.') self.validation_package_id.errors.append('Please select a package to validate against when pre-upload validation is enabled.')
return False return False
# Add check: Conditional uploads only make sense for individual mode
if self.use_conditional_uploads.data and self.upload_mode.data == 'transaction': if self.use_conditional_uploads.data and self.upload_mode.data == 'transaction':
self.use_conditional_uploads.errors.append('Conditional Uploads only apply to the "Individual Resources" mode.') self.use_conditional_uploads.errors.append('Conditional Uploads only apply to the "Individual Resources" mode.')
# We might allow this combination but warn the user it has no effect, return False
# or enforce it here. Let's enforce for clarity. if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
# return False # Optional: Make this a hard validation failure self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
# Or just let it pass and ignore the flag in the backend for transaction mode. return False
pass # Let it pass for now, backend will ignore if mode is transaction if self.auth_type.data == 'basic':
if not self.username.data:
self.username.errors.append('Username is required for Basic Authentication.')
return False
if not self.password.data:
self.password.errors.append('Password is required for Basic Authentication.')
return False
return True
class FhirRequestForm(FlaskForm):
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
('none', 'None'),
('bearerToken', 'Bearer Token'),
('basicAuth', 'Basic Authentication')
], default='none', validators=[Optional()])
auth_token = StringField('Bearer Token', validators=[Optional()],
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
basic_auth_username = StringField('Username', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Username'})
basic_auth_password = PasswordField('Password', validators=[Optional()],
render_kw={'placeholder': 'Enter Basic Auth Password'})
submit = SubmitField('Send Request')
def validate(self, extra_validators=None):
if not super().validate(extra_validators):
return False
if self.fhir_server_url.data:
if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected for a custom URL.')
return False
if self.auth_type.data == 'basicAuth':
if not self.basic_auth_username.data:
self.basic_auth_username.errors.append('Username is required for Basic Authentication with a custom URL.')
return False
if not self.basic_auth_password.data:
self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.')
return False
return True return True

Binary file not shown.

View File

@ -18,5 +18,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:45.070781+00:00" "timestamp": "2025-05-04T12:29:17.475734+00:00"
} }

View File

@ -30,5 +30,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:20.523471+00:00" "timestamp": "2025-05-04T12:29:15.067826+00:00"
} }

View File

@ -5,5 +5,5 @@
"imported_dependencies": [], "imported_dependencies": [],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:29.230227+00:00" "timestamp": "2025-05-04T12:29:16.477868+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:41.588025+00:00" "timestamp": "2025-05-04T12:29:17.363719+00:00"
} }

View File

@ -18,5 +18,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:49.395594+00:00" "timestamp": "2025-05-04T12:29:17.590266+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:56.492512+00:00" "timestamp": "2025-05-04T12:29:18.256800+00:00"
} }

View File

@ -14,5 +14,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:46.943079+00:00" "timestamp": "2025-05-04T12:29:17.529611+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:54.857273+00:00" "timestamp": "2025-05-04T12:29:18.216757+00:00"
} }

View File

@ -10,5 +10,5 @@
], ],
"complies_with_profiles": [], "complies_with_profiles": [],
"imposed_profiles": [], "imposed_profiles": [],
"timestamp": "2025-04-17T04:04:37.703082+00:00" "timestamp": "2025-05-04T12:29:17.148041+00:00"
} }

View File

@ -1,6 +1,6 @@
#FileLock #FileLock
#Fri Apr 25 13:11:59 UTC 2025 #Sun May 04 12:29:20 UTC 2025
server=172.19.0.2\:35493 server=172.18.0.2\:34351
hostName=499bb2429005 hostName=1913c9e2ec9b
method=file method=file
id=1966d138790b0be6a4873c639ee5ac2e23787fd766d id=1969b45b76c42f20115290bfabb203a60dc75365e9d

Binary file not shown.

File diff suppressed because it is too large Load Diff

123
package.py Normal file
View File

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

View File

@ -5,5 +5,10 @@ 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
flasgger

File diff suppressed because it is too large Load Diff

233
setup_linux.sh Normal file
View File

@ -0,0 +1,233 @@
#!/bin/bash
# --- Configuration ---
REPO_URL="https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git"
CLONE_DIR="hapi-fhir-jpaserver"
SOURCE_CONFIG_DIR="hapi-fhir-Setup" # Assuming this is relative to the script's parent
CONFIG_FILE="application.yaml"
# --- Define Paths ---
# Note: Adjust SOURCE_CONFIG_PATH if SOURCE_CONFIG_DIR is not a sibling directory
# This assumes the script is run from a directory, and hapi-fhir-setup is at the same level
SOURCE_CONFIG_PATH="../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}"
DEST_CONFIG_PATH="${CLONE_DIR}/target/classes/${CONFIG_FILE}"
APP_MODE=""
# --- Error Handling Function ---
handle_error() {
echo "------------------------------------"
echo "An error occurred: $1"
echo "Script aborted."
echo "------------------------------------"
# Removed 'read -p "Press Enter to exit..."' as it's not typical for non-interactive CI/CD
exit 1
}
# === Prompt for Installation Mode ===
get_mode_choice() {
echo "Select Installation Mode:"
echo "1. Standalone (Includes local HAPI FHIR Server - Requires Git & Maven)"
echo "2. Lite (Excludes local HAPI FHIR Server - No Git/Maven needed)"
while true; do
read -r -p "Enter your choice (1 or 2): " choice
case "$choice" in
1)
APP_MODE="standalone"
break
;;
2)
APP_MODE="lite"
break
;;
*)
echo "Invalid input. Please try again."
;;
esac
done
echo "Selected Mode: $APP_MODE"
echo
}
# Call the function to get mode choice
get_mode_choice
# === Conditionally Execute HAPI Setup ===
if [ "$APP_MODE" = "standalone" ]; then
echo "Running Standalone setup including HAPI FHIR..."
echo
# --- Step 0: Clean up previous clone (optional) ---
echo "Checking for existing directory: $CLONE_DIR"
if [ -d "$CLONE_DIR" ]; then
echo "Found existing directory, removing it..."
rm -rf "$CLONE_DIR"
if [ $? -ne 0 ]; then
handle_error "Failed to remove existing directory: $CLONE_DIR"
fi
echo "Existing directory removed."
else
echo "Directory does not exist, proceeding with clone."
fi
echo
# --- Step 1: Clone the HAPI FHIR server repository ---
echo "Cloning repository: $REPO_URL into $CLONE_DIR..."
git clone "$REPO_URL" "$CLONE_DIR"
if [ $? -ne 0 ]; then
handle_error "Failed to clone repository. Check Git installation and network connection."
fi
echo "Repository cloned successfully."
echo
# --- Step 2: Navigate into the cloned directory ---
echo "Changing directory to $CLONE_DIR..."
cd "$CLONE_DIR" || handle_error "Failed to change directory to $CLONE_DIR."
echo "Current directory: $(pwd)"
echo
# --- Step 3: Build the HAPI server using Maven ---
echo "===> Starting Maven build (Step 3)..."
mvn clean package -DskipTests=true -Pboot
if [ $? -ne 0 ]; then
echo "ERROR: Maven build failed."
cd ..
handle_error "Maven build process resulted in an error."
fi
echo "Maven build completed successfully."
echo
# --- Step 4: Copy the configuration file ---
echo "===> Starting file copy (Step 4)..."
echo "Copying configuration file..."
# Corrected SOURCE_CONFIG_PATH to be relative to the new current directory ($CLONE_DIR)
# This assumes the original script's SOURCE_CONFIG_PATH was relative to its execution location
# If SOURCE_CONFIG_DIR is ../hapi-fhir-setup relative to script's original location:
# Then from within CLONE_DIR, it becomes ../../hapi-fhir-setup
# We defined SOURCE_CONFIG_PATH earlier relative to the script start.
# So, when inside CLONE_DIR, the path from original script location should be used.
# The original script had: set SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%
# And then: xcopy "%SOURCE_CONFIG_PATH%" "target\classes\"
# This implies SOURCE_CONFIG_PATH is relative to the original script's location, not the $CLONE_DIR
# Therefore, we need to construct the correct relative path from *within* $CLONE_DIR back to the source.
# Assuming the script is in dir X, and SOURCE_CONFIG_DIR is ../hapi-fhir-setup from X.
# So, hapi-fhir-setup is a sibling of X's parent.
# If CLONE_DIR is also in X, then from within CLONE_DIR, the path is ../ + original SOURCE_CONFIG_PATH
# For simplicity and robustness, let's use an absolute path or a more clearly defined relative path from the start.
# The original `SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%` implies
# that `hapi-fhir-setup` is a sibling of the directory where the script *is being run from*.
# Let's assume the script is run from the root of FHIRFLARE-IG-Toolkit.
# And hapi-fhir-setup is also in the root, next to this script.
# Then SOURCE_CONFIG_PATH would be ./hapi-fhir-setup/target/classes/application.yaml
# And from within ./hapi-fhir-jpaserver/, the path would be ../hapi-fhir-setup/target/classes/application.yaml
# The original batch file sets SOURCE_CONFIG_PATH as "..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%"
# And COPIES it to "target\classes\" *while inside CLONE_DIR*.
# This means the source path is relative to where the *cd %CLONE_DIR%* happened from.
# Let's make it relative to the script's initial execution directory.
INITIAL_SCRIPT_DIR=$(pwd)
ABSOLUTE_SOURCE_CONFIG_PATH="${INITIAL_SCRIPT_DIR}/../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}" # This matches the ..\ logic
echo "Source: $ABSOLUTE_SOURCE_CONFIG_PATH"
echo "Destination: target/classes/$CONFIG_FILE"
if [ ! -f "$ABSOLUTE_SOURCE_CONFIG_PATH" ]; then
echo "WARNING: Source configuration file not found at $ABSOLUTE_SOURCE_CONFIG_PATH."
echo "The script will continue, but the server might use default configuration."
else
cp "$ABSOLUTE_SOURCE_CONFIG_PATH" "target/classes/"
if [ $? -ne 0 ]; then
echo "WARNING: Failed to copy configuration file. Check if the source file exists and permissions."
echo "The script will continue, but the server might use default configuration."
else
echo "Configuration file copied successfully."
fi
fi
echo
# --- Step 5: Navigate back to the parent directory ---
echo "===> Changing directory back (Step 5)..."
cd .. || handle_error "Failed to change back to the parent directory."
echo "Current directory: $(pwd)"
echo
else # APP_MODE is "lite"
echo "Running Lite setup, skipping HAPI FHIR build..."
# Ensure the hapi-fhir-jpaserver directory doesn't exist or is empty if Lite mode is chosen
if [ -d "$CLONE_DIR" ]; then
echo "Found existing HAPI directory ($CLONE_DIR) in Lite mode. Removing it..."
rm -rf "$CLONE_DIR"
fi
# Create empty target directories expected by Dockerfile COPY, even if not used
mkdir -p "${CLONE_DIR}/target/classes"
mkdir -p "${CLONE_DIR}/custom" # This was in the original batch, ensure it's here
# Create a placeholder empty WAR file and application.yaml to satisfy Dockerfile COPY
touch "${CLONE_DIR}/target/ROOT.war"
touch "${CLONE_DIR}/target/classes/application.yaml"
echo "Placeholder files and directories created for Lite mode build in $CLONE_DIR."
echo
fi
# === Modify docker-compose.yml to set APP_MODE ===
echo "Updating docker-compose.yml with APP_MODE=$APP_MODE..."
DOCKER_COMPOSE_TMP="docker-compose.yml.tmp"
DOCKER_COMPOSE_ORIG="docker-compose.yml"
cat << EOF > "$DOCKER_COMPOSE_TMP"
version: '3.8'
services:
fhirflare:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
- "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
volumes:
- ./instance:/app/instance
- ./static/uploads:/app/static/uploads
- ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
- ./logs:/app/logs
environment:
- FLASK_APP=app.py
- FLASK_ENV=development
- NODE_PATH=/usr/lib/node_modules
- APP_MODE=${APP_MODE}
- APP_BASE_URL=http://localhost:5000
- HAPI_FHIR_URL=http://localhost:8080/fhir
command: supervisord -c /etc/supervisord.conf
EOF
if [ ! -f "$DOCKER_COMPOSE_TMP" ]; then
handle_error "Failed to create temporary docker-compose file ($DOCKER_COMPOSE_TMP)."
fi
# Replace the original docker-compose.yml
mv "$DOCKER_COMPOSE_TMP" "$DOCKER_COMPOSE_ORIG"
echo "docker-compose.yml updated successfully."
echo
# --- Step 6: Build Docker images ---
echo "===> Starting Docker build (Step 6)..."
docker-compose build --no-cache
if [ $? -ne 0 ]; then
handle_error "Docker Compose build failed. Check Docker installation and docker-compose.yml file."
fi
echo "Docker images built successfully."
echo
# --- Step 7: Start Docker containers ---
echo "===> Starting Docker containers (Step 7)..."
docker-compose up -d
if [ $? -ne 0 ]; then
handle_error "Docker Compose up failed. Check Docker installation and container configurations."
fi
echo "Docker containers started successfully."
echo
echo "===================================="
echo "Script finished successfully! (Mode: $APP_MODE)"
echo "===================================="
exit 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

After

Width:  |  Height:  |  Size: 706 KiB

File diff suppressed because one or more lines are too long

390
static/css/animation.css Normal file
View File

@ -0,0 +1,390 @@
body {
width: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Apply overflow and height constraints only for fire animation page */
body.fire-animation-page {
overflow: hidden;
height: 100vh;
}
/* Fire animation overlay */
.fire-on {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(#1d4456, #112630);
opacity: 1;
z-index: 1;
transition: all 1200ms linear;
}
.section-center {
position: relative;
width: 300px; /* Reduced size for landing page */
height: 300px;
margin: 0 auto;
display: block;
overflow: hidden;
border: 8px solid rgba(0,0,0,.2);
border-radius: 50%;
z-index: 5;
background-color: #1d4456;
box-shadow: 0 0 50px 5px rgba(255,148,0,.1);
transition: all 500ms linear;
}
/* Wood and star using local images */
.wood {
position: absolute;
z-index: 21;
left: 50%;
bottom: 12%;
width: 80px;
margin-left: -40px;
height: 30px;
background-image: url('{{ url_for('static', filename='img/wood.png') }}');
background-size: 80px 30px;
border-radius: 5px;
}
.star {
z-index: 2;
position: absolute;
top: 138px;
left: 18px;
background-image: url('{{ url_for('static', filename='img/star.png') }}');
background-size: 11px 11px;
width: 11px;
height: 11px;
opacity: 0.4;
animation: starShine 3.5s linear infinite;
transition: all 1200ms linear;
}
.wood-circle {
position: absolute;
z-index: 20;
left: 50%;
bottom: 11%;
width: 100px;
margin-left: -50px;
height: 20px;
border-radius: 100%;
background-color: #0a171d;
}
.circle {
position: absolute;
z-index: 6;
right: -225px;
bottom: -337px;
width: 562px;
height: 525px;
border-radius: 100%;
background-color: #112630;
}
/* Moon */
.moon {
position: absolute;
top: 37px;
left: 86px;
width: 60px;
height: 60px;
background-color: #b2b7bc;
border-radius: 50%;
box-shadow: inset -15px 1.5px 0 0px #c0c3c9, 0 0 7px 3px rgba(228,228,222,.4);
z-index: 1;
animation: brilla-moon 4s alternate infinite;
transition: all 2000ms linear;
}
.moon div:nth-child(1) {
position: absolute;
top: 50%;
left: 10%;
width: 12%;
height: 12%;
border-radius: 50%;
border: 1px solid #adaca2;
box-shadow: inset 1.5px -0.75px 0 0px #85868b;
opacity: 0.4;
}
.moon div:nth-child(2) {
position: absolute;
top: 20%;
left: 38%;
width: 16%;
height: 16%;
border-radius: 50%;
border: 1px solid #adaca2;
box-shadow: inset 1.5px -0.75px 0 0px #85868b;
opacity: 0.4;
}
.moon div:nth-child(3) {
position: absolute;
top: 60%;
left: 45%;
width: 20%;
height: 20%;
border-radius: 50%;
border: 1px solid #adaca2;
box-shadow: inset 1.5px -0.75px 0 0px #85868b;
opacity: 0.4;
}
@keyframes brilla-moon {
0% { box-shadow: inset -15px 1.5px 0 0px #c0c3c9, 0 0 7px 3px rgba(228,228,222,.4); }
50% { box-shadow: inset -15px 1.5px 0 0px #c0c3c9, 0 0 11px 6px rgba(228,228,222,.4); }
}
/* Shooting stars */
.shooting-star {
z-index: 2;
width: 1px;
height: 37px;
border-bottom-left-radius: 50%;
border-bottom-right-radius: 50%;
position: absolute;
top: 0;
left: -52px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), white);
animation: animShootingStar 6s linear infinite;
transition: all 2000ms linear;
}
@keyframes animShootingStar {
from { transform: translateY(0px) translateX(0px) rotate(-45deg); opacity: 1; height: 3px; }
to { transform: translateY(960px) translateX(960px) rotate(-45deg); opacity: 1; height: 600px; }
}
.shooting-star-2 {
z-index: 2;
width: 1px;
height: 37px;
border-bottom-left-radius: 50%;
border-bottom-right-radius: 50%;
position: absolute;
top: 0;
left: 150px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), white);
animation: animShootingStar-2 9s linear infinite;
transition: all 2000ms linear;
}
@keyframes animShootingStar-2 {
from { transform: translateY(0px) translateX(0px) rotate(-45deg); opacity: 1; height: 3px; }
to { transform: translateY(1440px) translateX(1440px) rotate(-45deg); opacity: 1; height: 600px; }
}
/* Stars */
.star.snd { top: 75px; left: 232px; animation-delay: 1s; }
.star.trd { top: 97px; left: 75px; animation-delay: 1.4s; }
.star.fth { top: 15px; left: 150px; animation-delay: 1.8s; }
.star.fith { top: 63px; left: 165px; animation-delay: 2.2s; }
@keyframes starShine {
0% { transform: scale(0.3) rotate(0deg); opacity: 0.4; }
25% { transform: scale(1) rotate(360deg); opacity: 1; }
50% { transform: scale(0.3) rotate(720deg); opacity: 0.4; }
100% { transform: scale(0.3) rotate(0deg); opacity: 0.4; }
}
/* Trees */
.tree-1 {
position: relative;
top: 112px;
left: 37px;
width: 0;
height: 0;
z-index: 8;
border-bottom: 67px solid #0a171d;
border-left: 22px solid transparent;
border-right: 22px solid transparent;
}
.tree-1:before {
position: absolute;
bottom: -82px;
left: 50%;
margin-left: -3px;
width: 6px;
height: 22px;
z-index: 7;
content: '';
background-color: #000;
}
.tree-2 {
position: relative;
top: 0;
left: 187px;
width: 0;
height: 0;
z-index: 8;
border-bottom: 67px solid #0a171d;
border-left: 22px solid transparent;
border-right: 22px solid transparent;
}
.tree-2:before {
position: absolute;
bottom: -82px;
left: 50%;
margin-left: -3px;
width: 6px;
height: 22px;
z-index: 7;
content: '';
background-color: #000;
}
/* Fire */
.fire {
position: absolute;
z-index: 39;
width: 2px;
margin-left: -1px;
left: 50%;
bottom: 60px;
transition: all 1200ms linear;
}
.fire span {
display: block;
position: absolute;
bottom: -11px;
margin-left: -15px;
height: 0;
width: 0;
border: 22px solid #febd08; /* Main flame: yellow-orange */
border-radius: 50%;
border-top-left-radius: 0;
left: -6px;
box-shadow: 0 0 7px 3px rgba(244,110,28,0.8), 0 0 15px 7px rgba(244,110,28,0.6), 0 0 22px 11px rgba(244,110,28,0.3);
transform: scale(0.45, 0.75) rotate(45deg);
animation: brilla-fire 2.5s alternate infinite;
z-index: 9;
transition: all 1200ms linear;
}
.fire span:nth-child(2) {
left: -16px;
border: 22px solid #e63946; /* Outside flame: red */
box-shadow: 0 0 7px 3px rgba(230,57,70,0.8), 0 0 15px 7px rgba(230,57,70,0.6), 0 0 22px 11px rgba(230,57,70,0.3);
transform: scale(0.3, 0.55) rotate(15deg);
z-index: 8;
animation: brilla-fire-red 1.5s alternate infinite;
}
.fire span:nth-child(3) {
left: 3px;
border: 22px solid #e63946; /* Outside flame: red */
box-shadow: 0 0 7px 3px rgba(230,57,70,0.8), 0 0 15px 7px rgba(230,57,70,0.6), 0 0 22px 11px rgba(230,57,70,0.3);
transform: scale(0.3, 0.55) rotate(80deg);
z-index: 8;
animation: brilla-fire-red 2s alternate infinite;
}
.fire span:after {
display: block;
position: absolute;
bottom: -22px;
content: '';
margin-left: -3px;
height: 22px;
width: 9px;
background-color: rgba(244,110,28,0.7);
border-radius: 80px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: 0 0 15px 7px rgba(244,110,28,0.7);
left: -6px;
opacity: 0.8;
transform: rotate(-50deg);
}
.fire span:nth-child(2):after {
background-color: rgba(230,57,70,0.7); /* Match red flame */
box-shadow: 0 0 15px 7px rgba(230,57,70,0.7);
}
.fire span:nth-child(3):after {
background-color: rgba(230,57,70,0.7); /* Match red flame */
box-shadow: 0 0 15px 7px rgba(230,57,70,0.7);
}
@keyframes brilla-fire {
0%, 100% { box-shadow: 0 0 7px 3px rgba(244,110,28,0.8), 0 0 15px 7px rgba(244,110,28,0.6), 0 0 22px 11px rgba(244,110,28,0.3); }
50% { box-shadow: 0 0 10px 5px rgba(244,110,28,0.8), 0 0 21px 10px rgba(244,110,28,0.6), 0 0 31px 15px rgba(244,110,28,0.3); }
}
@keyframes brilla-fire-red {
0%, 100% { box-shadow: 0 0 7px 3px rgba(230,57,70,0.8), 0 0 15px 7px rgba(230,57,70,0.6), 0 0 22px 11px rgba(230,57,70,0.3); }
50% { box-shadow: 0 0 10px 5px rgba(230,57,70,0.8), 0 0 21px 10px rgba(230,57,70,0.6), 0 0 31px 15px rgba(230,57,70,0.3); }
}
/* Smoke */
.smoke {
position: absolute;
z-index: 40;
width: 2px;
margin-left: -1px;
left: 50%;
bottom: 79px;
opacity: 0;
transition: all 800ms linear;
}
.smoke span {
display: block;
position: absolute;
bottom: -26px;
left: 50%;
margin-left: -15px;
height: 0;
width: 0;
border: 22px solid rgba(0, 0, 0, .6);
border-radius: 16px;
border-bottom-left-radius: 0;
border-top-right-radius: 0;
left: -6px;
opacity: 0;
transform: scale(0.2, 0.2) rotate(-45deg);
}
@keyframes smokeLeft {
0% { transform: scale(0.2, 0.2) translate(0, 0) rotate(-45deg); }
10% { opacity: 1; transform: scale(0.2, 0.3) translate(0, -3px) rotate(-45deg); }
60% { opacity: 0.6; transform: scale(0.3, 0.5) translate(-7px, -60px) rotate(-45deg); }
100% { opacity: 0; transform: scale(0.4, 0.8) translate(-15px, -90px) rotate(-45deg); }
}
@keyframes smokeRight {
0% { transform: scale(0.2, 0.2) translate(0, 0) rotate(-45deg); }
10% { opacity: 1; transform: scale(0.2, 0.3) translate(0, -3px) rotate(-45deg); }
60% { opacity: 0.6; transform: scale(0.3, 0.5) translate(7px, -60px) rotate(-45deg); }
100% { opacity: 0; transform: scale(0.4, 0.8) translate(15px, -90px) rotate(-45deg); }
}
.smoke .s-0 { animation: smokeLeft 7s 0s infinite; }
.smoke .s-1 { animation: smokeRight 7s 0.7s infinite; }
.smoke .s-2 { animation: smokeLeft 7s 1.4s infinite; }
.smoke .s-3 { animation: smokeRight 7s 2.1s infinite; }
.smoke .s-4 { animation: smokeLeft 7s 2.8s infinite; }
.smoke .s-5 { animation: smokeRight 7s 3.5s infinite; }
.smoke .s-6 { animation: smokeLeft 7s 4.2s infinite; }
.smoke .s-7 { animation: smokeRight 7s 4.9s infinite; }
.smoke .s-8 { animation: smokeLeft 7s 5.6s infinite; }
.smoke .s-9 { animation: smokeRight 7s 6.3s infinite; }
/* Fire-off state (light theme) */
body:not(.fire-on) .section-center { box-shadow: 0 0 50px 5px rgba(200,200,200,.2); }
body:not(.fire-on) .smoke { opacity: 1; transition-delay: 0.8s; }
body:not(.fire-on) .fire span { bottom: -26px; transform: scale(0.15, 0.15) rotate(45deg); }

View File

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

BIN
static/img/star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

BIN
static/img/wood.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -1,15 +1,22 @@
<!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">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" xintegrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-okaidia.min.css" rel="stylesheet" /> <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-okaidia.min.css" rel="stylesheet" />
<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/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;
@ -177,44 +184,44 @@
background: inherit; /* Ensure background comes from pre or theme */ background: inherit; /* Ensure background comes from pre or theme */
} }
/* General Button Theme (Primary, Secondary) */ /* General Button Theme (Primary, Secondary) */
.btn-primary { .btn-primary {
background-color: #007bff; background-color: #007bff;
border-color: #007bff; border-color: #007bff;
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #0056b3; background-color: #0056b3;
border-color: #004085; border-color: #004085;
} }
.btn-outline-primary { .btn-outline-primary {
color: #007bff; color: #007bff;
border-color: #007bff; border-color: #007bff;
} }
.btn-outline-primary:hover { .btn-outline-primary:hover {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
border-color: #6c757d; border-color: #6c757d;
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: #5a6268;
border-color: #545b62; border-color: #545b62;
} }
.btn-outline-secondary { .btn-outline-secondary {
color: #6c757d; color: #6c757d;
border-color: #6c757d; border-color: #6c757d;
} }
.btn-outline-secondary:hover { .btn-outline-secondary:hover {
background-color: #6c757d; background-color: #6c757d;
color: white; color: white;
} }
/* --- Prism.js Theme Overrides for Light Mode --- */ /* --- Prism.js Theme Overrides for Light Mode --- */
/* Target code blocks only when NOT in dark theme */ /* Target code blocks only when NOT in dark theme */
html:not([data-theme="dark"]) .token.punctuation, html:not([data-theme="dark"]) .token.punctuation,
@ -256,19 +263,19 @@
/* --- End Prism.js Overrides --- */ /* --- End Prism.js Overrides --- */
/* --- Theme Styles for FSH Code Blocks --- */ /* --- Theme Styles for FSH Code Blocks --- */
pre, pre code { pre, pre code {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875em; font-size: 0.875em;
white-space: pre-wrap; /* Allow wrapping */ white-space: pre-wrap; /* Allow wrapping */
word-break: break-all; /* Break long lines */ word-break: break-all; /* Break long lines */
} }
/* Target pre blocks specifically used for FSH output if needed */ /* Target pre blocks specifically used for FSH output if needed */
/* If the pre block has a specific class or ID, use that */ /* If the pre block has a specific class or ID, use that */
#fsh-output pre, /* Targets any pre within the output container */ #fsh-output pre, /* Targets any pre within the output container */
._fsh_output pre /* Targets any pre within the partial template content */ ._fsh_output pre /* Targets any pre within the partial template content */
{ {
background-color: #e9ecef; /* Light theme background */ background-color: #e9ecef; /* Light theme background */
color: #212529; /* Light theme text */ color: #212529; /* Light theme text */
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@ -276,297 +283,294 @@ pre, pre code {
padding: 10px; padding: 10px;
max-height: 600px; /* Adjust as needed */ max-height: 600px; /* Adjust as needed */
overflow: auto; overflow: auto;
} }
#fsh-output pre code, #fsh-output pre code,
._fsh_output pre code { ._fsh_output pre code {
background-color: transparent !important; /* Ensure code tag doesn't override pre bg */ background-color: transparent !important; /* Ensure code tag doesn't override pre bg */
padding: 0; padding: 0;
color: inherit; /* Inherit text color from pre */ color: inherit; /* Inherit text color from pre */
font-size: inherit; /* Inherit font size from pre */ font-size: inherit; /* Inherit font size from pre */
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) --- */
/* Operation Block Container */ /* Operation Block Container */
.opblock { .opblock {
border: 1px solid #dee2e6; /* Light theme border */ border: 1px solid #dee2e6; /* Light theme border */
background: #ffffff; /* Light theme background */ background: #ffffff; /* Light theme background */
color: #212529; /* Light theme text */ color: #212529; /* Light theme text */
} }
html[data-theme="dark"] .opblock { html[data-theme="dark"] .opblock {
border-color: #495057; /* Dark theme border */ border-color: #495057; /* Dark theme border */
background: #343a40; /* Dark theme background (card bg) */ background: #343a40; /* Dark theme background (card bg) */
color: #f8f9fa; /* Dark theme text */ color: #f8f9fa; /* Dark theme text */
} }
/* Operation Summary (Header) */ /* Operation Summary (Header) */
.opblock-summary { .opblock-summary {
background: #f8f9fa; /* Light theme summary bg */ background: #f8f9fa; /* Light theme summary bg */
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
color: #212529; /* Default text color for summary */ color: #212529; /* Default text color for summary */
} }
.opblock-summary:hover { .opblock-summary:hover {
background: #e9ecef; background: #e9ecef;
} }
html[data-theme="dark"] .opblock-summary { html[data-theme="dark"] .opblock-summary {
background: #40464c; /* Darker summary bg */ background: #40464c; /* Darker summary bg */
border-bottom-color: #495057; border-bottom-color: #495057;
color: #f8f9fa; color: #f8f9fa;
} }
html[data-theme="dark"] .opblock-summary:hover { html[data-theme="dark"] .opblock-summary:hover {
background: #495057; background: #495057;
} }
/* Keep method badge colors fixed - DO NOT override .opblock-summary-method */ /* Keep method badge colors fixed - DO NOT override .opblock-summary-method */
.opblock-summary-description { .opblock-summary-description {
color: #495057; /* Slightly muted description text */ color: #495057; /* Slightly muted description text */
} }
html[data-theme="dark"] .opblock-summary-description { html[data-theme="dark"] .opblock-summary-description {
color: #adb5bd; /* Lighter muted text for dark */ color: #adb5bd; /* Lighter muted text for dark */
} }
/* Operation Body (Expanded Section) */ /* Operation Body (Expanded Section) */
.opblock-body { .opblock-body {
border-top: 1px solid #dee2e6; border-top: 1px solid #dee2e6;
} }
html[data-theme="dark"] .opblock-body { html[data-theme="dark"] .opblock-body {
border-top-color: #495057; border-top-color: #495057;
} }
/* Section Headers within Body */ /* Section Headers within Body */
.opblock-section-header { .opblock-section-header {
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
color: inherit; /* Inherit from .opblock */ color: inherit; /* Inherit from .opblock */
} }
html[data-theme="dark"] .opblock-section-header { html[data-theme="dark"] .opblock-section-header {
border-bottom-color: #495057; border-bottom-color: #495057;
} }
.opblock-title { .opblock-title {
color: inherit; /* Inherit from .opblock */ color: inherit; /* Inherit from .opblock */
} }
/* Parameters Table */ /* Parameters Table */
.parameters-table th, .parameters-table th,
.parameters-table td { .parameters-table td {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
color: inherit; /* Inherit from .opblock */ color: inherit; /* Inherit from .opblock */
} }
html[data-theme="dark"] .parameters-table th, html[data-theme="dark"] .parameters-table th,
html[data-theme="dark"] .parameters-table td { html[data-theme="dark"] .parameters-table td {
border-color: #495057; border-color: #495057;
} }
.parameter__type, .parameter__type,
.parameter__in { .parameter__in {
color: #6c757d; /* Use standard muted color */ color: #6c757d; /* Use standard muted color */
} }
html[data-theme="dark"] .parameter__type, html[data-theme="dark"] .parameter__type,
html[data-theme="dark"] .parameter__in { html[data-theme="dark"] .parameter__in {
color: #adb5bd; /* Use standard dark muted color */ color: #adb5bd; /* Use standard dark muted color */
} }
.parameters-col_description input[type="text"], .parameters-col_description input[type="text"],
.parameters-col_description input[type="number"] { .parameters-col_description input[type="number"] {
background-color: #fff; /* Match light form controls */ background-color: #fff; /* Match light form controls */
border: 1px solid #ced4da; border: 1px solid #ced4da;
color: #212529; color: #212529;
} }
html[data-theme="dark"] .parameters-col_description input[type="text"], html[data-theme="dark"] .parameters-col_description input[type="text"],
html[data-theme="dark"] .parameters-col_description input[type="number"] { html[data-theme="dark"] .parameters-col_description input[type="number"] {
background-color: #495057; /* Match dark form controls */ background-color: #495057; /* Match dark form controls */
border-color: #6c757d; border-color: #6c757d;
color: #f8f9fa; color: #f8f9fa;
} }
/* Request Body Textarea */ /* Request Body Textarea */
.request-body-textarea { .request-body-textarea {
background-color: #fff; /* Match light form controls */ background-color: #fff; /* Match light form controls */
border: 1px solid #ced4da; border: 1px solid #ced4da;
color: #212529; color: #212529;
} }
html[data-theme="dark"] .request-body-textarea { html[data-theme="dark"] .request-body-textarea {
background-color: #495057; /* Match dark form controls */ background-color: #495057; /* Match dark form controls */
border-color: #6c757d; border-color: #6c757d;
color: #f8f9fa; color: #f8f9fa;
} }
/* Responses Table */ /* Responses Table */
.responses-table th, .responses-table th,
.responses-table td { .responses-table td {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
color: inherit; color: inherit;
} }
html[data-theme="dark"] .responses-table th, html[data-theme="dark"] .responses-table th,
html[data-theme="dark"] .responses-table td { html[data-theme="dark"] .responses-table td {
border-color: #495057; border-color: #495057;
} }
/* Example/Schema Tabs & Controls */ /* Example/Schema Tabs & Controls */
.response-control-media-type { .response-control-media-type {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
html[data-theme="dark"] .response-control-media-type { html[data-theme="dark"] .response-control-media-type {
background-color: #40464c; /* Match summary header bg */ background-color: #40464c; /* Match summary header bg */
} }
.tablinks.badge { /* Default inactive tab */ .tablinks.badge { /* Default inactive tab */
background: #dee2e6 !important; /* Light grey inactive */ background: #dee2e6 !important; /* Light grey inactive */
color: #333 !important; color: #333 !important;
} }
.tablinks.badge.active { /* Default active tab */ .tablinks.badge.active { /* Default active tab */
background: #007bff !important; /* Use primary color */ background: #007bff !important; /* Use primary color */
color: white !important; color: white !important;
} }
html[data-theme="dark"] .tablinks.badge { /* Dark inactive tab */ html[data-theme="dark"] .tablinks.badge { /* Dark inactive tab */
background: #6c757d !important; /* Dark grey inactive */ background: #6c757d !important; /* Dark grey inactive */
color: white !important; color: white !important;
} }
html[data-theme="dark"] .tablinks.badge.active { /* Dark active tab */ html[data-theme="dark"] .tablinks.badge.active { /* Dark active tab */
background: #4dabf7 !important; /* Use dark primary color */ background: #4dabf7 !important; /* Use dark primary color */
color: #000 !important; color: #000 !important;
} }
/* Code/Output Blocks */ /* Code/Output Blocks */
.highlight-code, .highlight-code,
.request-url-output, .request-url-output,
.curl-output, .curl-output,
.execute-wrapper pre.response-output-content { .execute-wrapper pre.response-output-content {
background: #e9ecef; /* Light theme pre background */ background: #e9ecef; /* Light theme pre background */
color: #212529; color: #212529;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
} }
html[data-theme="dark"] .highlight-code, html[data-theme="dark"] .highlight-code,
html[data-theme="dark"] .request-url-output, html[data-theme="dark"] .request-url-output,
html[data-theme="dark"] .curl-output, html[data-theme="dark"] .curl-output,
html[data-theme="dark"] .execute-wrapper pre.response-output-content { html[data-theme="dark"] .execute-wrapper pre.response-output-content {
background: #495057; /* Dark theme pre background */ background: #495057; /* Dark theme pre background */
color: #f8f9fa; color: #f8f9fa;
border-color: #6c757d; border-color: #6c757d;
} }
/* Narrative Response Box */ /* Narrative Response Box */
.execute-wrapper div.response-output-narrative { .execute-wrapper div.response-output-narrative {
background: #ffffff; background: #ffffff;
color: #212529; color: #212529;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
} }
html[data-theme="dark"] .execute-wrapper div.response-output-narrative { html[data-theme="dark"] .execute-wrapper div.response-output-narrative {
background: #343a40; /* Match card body */ background: #343a40; /* Match card body */
color: #f8f9fa; color: #f8f9fa;
border: 1px solid #495057; border: 1px solid #495057;
} }
/* Response Status Text */ /* Response Status Text */
.execute-wrapper .response-status[style*="color: green"] { color: #198754 !important; } .execute-wrapper .response-status[style*="color: green"] { color: #198754 !important; }
.execute-wrapper .response-status[style*="color: red"] { color: #dc3545 !important; } .execute-wrapper .response-status[style*="color: red"] { color: #dc3545 !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: 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) */
.btn-success { background-color: #198754; border-color: #198754; color: white; }
.btn-success:hover { background-color: #157347; border-color: #146c43; }
.btn-outline-success { color: #198754; border-color: #198754; }
.btn-outline-success:hover { background-color: #198754; color: white; }
/* Specific Action Buttons (Success, Danger, Warning, Info) */ .btn-danger { background-color: #dc3545; border-color: #dc3545; color: white; }
.btn-success { background-color: #198754; border-color: #198754; color: white; } .btn-danger:hover { background-color: #bb2d3b; border-color: #b02a37; }
.btn-success:hover { background-color: #157347; border-color: #146c43; } .btn-outline-danger { color: #dc3545; border-color: #dc3545; }
.btn-outline-success { color: #198754; border-color: #198754; } .btn-outline-danger:hover { background-color: #dc3545; color: white; }
.btn-outline-success:hover { background-color: #198754; color: white; }
.btn-danger { background-color: #dc3545; border-color: #dc3545; color: white; } .btn-warning { background-color: #ffc107; border-color: #ffc107; color: #000; }
.btn-danger:hover { background-color: #bb2d3b; border-color: #b02a37; } .btn-warning:hover { background-color: #ffca2c; border-color: #ffc720; }
.btn-outline-danger { color: #dc3545; border-color: #dc3545; } .btn-outline-warning { color: #ffc107; border-color: #ffc107; }
.btn-outline-danger:hover { background-color: #dc3545; color: white; } .btn-outline-warning:hover { background-color: #ffc107; color: #000; }
.btn-warning { background-color: #ffc107; border-color: #ffc107; color: #000; } .btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #000; }
.btn-warning:hover { background-color: #ffca2c; border-color: #ffc720; } .btn-info:hover { background-color: #31d2f2; border-color: #25cff2; }
.btn-outline-warning { color: #ffc107; border-color: #ffc107; } .btn-outline-info { color: #0dcaf0; border-color: #0dcaf0; }
.btn-outline-warning:hover { background-color: #ffc107; color: #000; } .btn-outline-info:hover { background-color: #0dcaf0; color: #000; }
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #000; } /* General Badge Theme */
.btn-info:hover { background-color: #31d2f2; border-color: #25cff2; } .badge {
.btn-outline-info { color: #0dcaf0; border-color: #0dcaf0; }
.btn-outline-info:hover { background-color: #0dcaf0; color: #000; }
/* General Badge Theme */
.badge {
padding: 0.4em 0.6em; /* Slightly larger padding */ padding: 0.4em 0.6em; /* Slightly larger padding */
font-size: 0.85em; font-size: 0.85em;
font-weight: 600; font-weight: 600;
} }
/* Specific Badge Backgrounds (Add more as needed) */ /* Specific Badge Backgrounds (Add more as needed) */
.badge.bg-primary { background-color: #007bff !important; color: white !important; } .badge.bg-primary { background-color: #007bff !important; color: white !important; }
.badge.bg-secondary { background-color: #6c757d !important; color: white !important; } .badge.bg-secondary { background-color: #6c757d !important; color: white !important; }
.badge.bg-success { background-color: #198754 !important; color: white !important; } .badge.bg-success { background-color: #198754 !important; color: white !important; }
.badge.bg-danger { background-color: #dc3545 !important; color: white !important; } .badge.bg-danger { background-color: #dc3545 !important; color: white !important; }
.badge.bg-warning { background-color: #ffc107 !important; color: #000 !important; } .badge.bg-warning { background-color: #ffc107 !important; color: #000 !important; }
.badge.bg-info { background-color: #0dcaf0 !important; color: #000 !important; } .badge.bg-info { background-color: #0dcaf0 !important; color: #000 !important; }
.badge.bg-light { background-color: #f8f9fa !important; color: #000 !important; border: 1px solid #dee2e6; } .badge.bg-light { background-color: #f8f9fa !important; color: #000 !important; border: 1px solid #dee2e6; }
.badge.bg-dark { background-color: #343a40 !important; color: white !important; } .badge.bg-dark { background-color: #343a40 !important; color: white !important; }
/* Copy Button Specific Style */ /* Copy Button Specific Style */
.btn-copy { /* Optional: Add a specific class if needed, or style .btn-outline-secondary directly */ .btn-copy { /* Optional: Add a specific class if needed, or style .btn-outline-secondary directly */
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
} }
/* --- Dark Theme Overrides --- */ /* --- Dark Theme Overrides --- */
html[data-theme="dark"] .btn-primary { background-color: #4dabf7; border-color: #4dabf7; color: #000; } html[data-theme="dark"] .btn-primary { background-color: #4dabf7; border-color: #4dabf7; color: #000; }
html[data-theme="dark"] .btn-primary:hover { background-color: #68bcef; border-color: #5fb7ea; } html[data-theme="dark"] .btn-primary:hover { background-color: #68bcef; border-color: #5fb7ea; }
html[data-theme="dark"] .btn-outline-primary { color: #4dabf7; border-color: #4dabf7; } html[data-theme="dark"] .btn-outline-primary { color: #4dabf7; border-color: #4dabf7; }
html[data-theme="dark"] .btn-outline-primary:hover { background-color: #4dabf7; color: #000; } html[data-theme="dark"] .btn-outline-primary:hover { background-color: #4dabf7; color: #000; }
html[data-theme="dark"] .btn-secondary { background-color: #6c757d; border-color: #6c757d; color: white; } html[data-theme="dark"] .btn-secondary { background-color: #6c757d; border-color: #6c757d; color: white; }
html[data-theme="dark"] .btn-secondary:hover { background-color: #5a6268; border-color: #545b62; } html[data-theme="dark"] .btn-secondary:hover { background-color: #5a6268; border-color: #545b62; }
html[data-theme="dark"] .btn-outline-secondary { color: #adb5bd; border-color: #6c757d; } /* Lighter text/border */ html[data-theme="dark"] .btn-outline-secondary { color: #adb5bd; border-color: #6c757d; } /* Lighter text/border */
html[data-theme="dark"] .btn-outline-secondary:hover { background-color: #6c757d; color: white; } html[data-theme="dark"] .btn-outline-secondary:hover { background-color: #6c757d; color: white; }
html[data-theme="dark"] .btn-success { background-color: #20c997; border-color: #20c997; color: #000; } html[data-theme="dark"] .btn-success { background-color: #20c997; border-color: #20c997; color: #000; }
html[data-theme="dark"] .btn-success:hover { background-color: #3ce0ac; border-color: #36d8a3; } html[data-theme="dark"] .btn-success:hover { background-color: #3ce0ac; border-color: #36d8a3; }
html[data-theme="dark"] .btn-outline-success { color: #20c997; border-color: #20c997; } html[data-theme="dark"] .btn-outline-success { color: #20c997; border-color: #20c997; }
html[data-theme="dark"] .btn-outline-success:hover { background-color: #20c997; color: #000; } html[data-theme="dark"] .btn-outline-success:hover { background-color: #20c997; color: #000; }
/* Keep danger as is */ /* Keep danger as is */
html[data-theme="dark"] .btn-danger { background-color: #e63946; border-color: #e63946; color: white; } html[data-theme="dark"] .btn-danger { background-color: #e63946; border-color: #e63946; color: white; }
html[data-theme="dark"] .btn-danger:hover { background-color: #c82b39; border-color: #bd2130; } html[data-theme="dark"] .btn-danger:hover { background-color: #c82b39; border-color: #bd2130; }
html[data-theme="dark"] .btn-outline-danger { color: #e63946; border-color: #e63946; } html[data-theme="dark"] .btn-outline-danger { color: #e63946; border-color: #e63946; }
html[data-theme="dark"] .btn-outline-danger:hover { background-color: #e63946; color: white; } html[data-theme="dark"] .btn-outline-danger:hover { background-color: #e63946; color: white; }
html[data-theme="dark"] .btn-warning { background-color: #ffca2c; border-color: #ffca2c; color: #000; } html[data-theme="dark"] .btn-warning { background-color: #ffca2c; border-color: #ffca2c; color: #000; }
html[data-theme="dark"] .btn-warning:hover { background-color: #ffd24a; border-color: #ffce3d; } html[data-theme="dark"] .btn-warning:hover { background-color: #ffd24a; border-color: #ffce3d; }
html[data-theme="dark"] .btn-outline-warning { color: #ffca2c; border-color: #ffca2c; } html[data-theme="dark"] .btn-outline-warning { color: #ffca2c; border-color: #ffca2c; }
html[data-theme="dark"] .btn-outline-warning:hover { background-color: #ffca2c; color: #000; } html[data-theme="dark"] .btn-outline-warning:hover { background-color: #ffca2c; color: #000; }
html[data-theme="dark"] .btn-info { background-color: #22b8cf; border-color: #22b8cf; color: #000; } html[data-theme="dark"] .btn-info { background-color: #22b8cf; border-color: #22b8cf; color: #000; }
html[data-theme="dark"] .btn-info:hover { background-color: #44cee3; border-color: #38cae0; } html[data-theme="dark"] .btn-info:hover { background-color: #44cee3; border-color: #38cae0; }
html[data-theme="dark"] .btn-outline-info { color: #22b8cf; border-color: #22b8cf; } html[data-theme="dark"] .btn-outline-info { color: #22b8cf; border-color: #22b8cf; }
html[data-theme="dark"] .btn-outline-info:hover { background-color: #22b8cf; color: #000; } html[data-theme="dark"] .btn-outline-info:hover { background-color: #22b8cf; color: #000; }
/* Dark Theme Badges */ /* Dark Theme Badges */
html[data-theme="dark"] .badge.bg-primary { background-color: #4dabf7 !important; color: #000 !important; } html[data-theme="dark"] .badge.bg-primary { background-color: #4dabf7 !important; color: #000 !important; }
html[data-theme="dark"] .badge.bg-secondary { background-color: #6c757d !important; color: white !important; } html[data-theme="dark"] .badge.bg-secondary { background-color: #6c757d !important; color: white !important; }
html[data-theme="dark"] .badge.bg-success { background-color: #20c997 !important; color: #000 !important; } html[data-theme="dark"] .badge.bg-success { background-color: #20c997 !important; color: #000 !important; }
html[data-theme="dark"] .badge.bg-danger { background-color: #e63946 !important; color: white !important; } html[data-theme="dark"] .badge.bg-danger { background-color: #e63946 !important; color: white !important; }
html[data-theme="dark"] .badge.bg-warning { background-color: #ffca2c !important; color: #000 !important; } html[data-theme="dark"] .badge.bg-warning { background-color: #ffca2c !important; color: #000 !important; }
html[data-theme="dark"] .badge.bg-info { background-color: #22b8cf !important; color: #000 !important; } html[data-theme="dark"] .badge.bg-info { background-color: #22b8cf !important; color: #000 !important; }
html[data-theme="dark"] .badge.bg-light { background-color: #495057 !important; color: #f8f9fa !important; border: 1px solid #6c757d; } html[data-theme="dark"] .badge.bg-light { background-color: #495057 !important; color: #f8f9fa !important; border: 1px solid #6c757d; }
html[data-theme="dark"] .badge.bg-dark { background-color: #adb5bd !important; color: #000 !important; } html[data-theme="dark"] .badge.bg-dark { background-color: #adb5bd !important; color: #000 !important; }
/* --- Themed Backgrounds for Code/Structure View (cp_view_processed_ig.html) --- */ /* --- Themed Backgrounds for Code/Structure View (cp_view_processed_ig.html) --- */
/* Styling for <pre> blocks containing code */ /* Styling for <pre> blocks containing code */
#raw-structure-wrapper pre, #raw-structure-wrapper pre,
#example-content-wrapper pre { #example-content-wrapper pre {
background-color: #e9ecef; /* Light theme background */ background-color: #e9ecef; /* Light theme background */
color: #212529; /* Light theme text */ color: #212529; /* Light theme text */
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@ -576,11 +580,11 @@ html[data-theme="dark"] .badge.bg-dark { background-color: #adb5bd !important; c
overflow-y: auto; /* Keep existing overflow */ overflow-y: auto; /* Keep existing overflow */
white-space: pre-wrap; /* Ensure wrapping */ white-space: pre-wrap; /* Ensure wrapping */
word-break: break-all; /* Break long strings */ word-break: break-all; /* Break long strings */
} }
/* Ensure code tag inside inherits background and has appropriate font */ /* Ensure code tag inside inherits background and has appropriate font */
#raw-structure-wrapper pre code, #raw-structure-wrapper pre code,
#example-content-wrapper pre code { #example-content-wrapper pre code {
background-color: transparent !important; /* Let pre handle background */ background-color: transparent !important; /* Let pre handle background */
padding: 0; padding: 0;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
@ -589,47 +593,47 @@ html[data-theme="dark"] .badge.bg-dark { background-color: #adb5bd !important; c
display: block; /* Make code block fill pre */ display: block; /* Make code block fill pre */
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
/* Styling for the Structure Definition tree list items */ /* Styling for the Structure Definition tree list items */
.structure-tree-root .list-group-item { .structure-tree-root .list-group-item {
background-color: #ffffff; /* Light theme default background */ background-color: #ffffff; /* Light theme default background */
border-color: #dee2e6; border-color: #dee2e6;
color: #212529; color: #212529;
} }
.structure-tree-root .list-group-item-warning { .structure-tree-root .list-group-item-warning {
background-color: #fff3cd; /* Bootstrap light theme warning */ background-color: #fff3cd; /* Bootstrap light theme warning */
border-color: #ffeeba; border-color: #ffeeba;
/*color: #856404; Ensure text is readable */ /*color: #856404; Ensure text is readable */
} }
/* --- Dark Theme Overrides for Code/Structure View --- */ /* --- Dark Theme Overrides for Code/Structure View --- */
html[data-theme="dark"] #raw-structure-wrapper pre, html[data-theme="dark"] #raw-structure-wrapper pre,
html[data-theme="dark"] #example-content-wrapper pre { html[data-theme="dark"] #example-content-wrapper 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;
} }
/* Dark theme code text color already inherited, but explicit doesn't hurt */ /* Dark theme code text color already inherited, but explicit doesn't hurt */
html[data-theme="dark"] #raw-structure-wrapper pre code, html[data-theme="dark"] #raw-structure-wrapper pre code,
html[data-theme="dark"] #example-content-wrapper pre code { html[data-theme="dark"] #example-content-wrapper pre code {
color: #f8f9fa; color: #f8f9fa;
} }
/* Dark theme structure list items */ /* Dark theme structure list items */
html[data-theme="dark"] .structure-tree-root .list-group-item { html[data-theme="dark"] .structure-tree-root .list-group-item {
background-color: #343a40; /* Dark theme card background */ background-color: #343a40; /* Dark theme card background */
border-color: #495057; border-color: #495057;
color: #f8f9fa; color: #f8f9fa;
} }
html[data-theme="dark"] .structure-tree-root .list-group-item-warning { html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
background-color: #664d03; /* Darker warning background */ background-color: #664d03; /* Darker warning background */
border-color: #997404; border-color: #997404;
/*color: #ffecb5; Lighter text for contrast */ /*color: #ffecb5; Lighter text for contrast */
} }
/* Dark Theme Styles */ /* Dark Theme Styles */
html[data-theme="dark"] body { html[data-theme="dark"] body {
@ -750,7 +754,7 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
} }
</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>
@ -763,10 +767,10 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<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 == '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>
@ -777,6 +781,9 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<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>
@ -786,6 +793,11 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<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">
@ -802,7 +814,6 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<main class="flex-grow-1"> <main class="flex-grow-1">
<div class="container mt-4"> <div class="container mt-4">
<!-- Flashed Messages Section -->
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="mt-3"> <div class="mt-3">
@ -837,6 +848,7 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/issues/new/choose" class="text-danger text-decoration-none" aria-label="FHIRFLARE support"><i class="fas fa-exclamation-circle me-1"></i> Raise an Issue</a> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/issues/new/choose" class="text-danger text-decoration-none" aria-label="FHIRFLARE support"><i class="fas fa-exclamation-circle me-1"></i> Raise an Issue</a>
</div> </div>
<div class="footer-right"> <div class="footer-right">
<a class="nav-link {{ 'active' if request.endpoint == 'flasgger.apidocs' else '' }}" href="{{ url_for('flasgger.apidocs') }}" aria-label="API Documentation"><i class="fas fa-book-open me-1"></i> API Docs</a>
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/discussions" target="_blank" rel="noreferrer" aria-label="Project Discussion">Project Discussions</a> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/discussions" target="_blank" rel="noreferrer" aria-label="Project Discussion">Project Discussions</a>
<a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a> <a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a>
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/blob/main/LICENSE.md" aria-label="License">License</a> <a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/blob/main/LICENSE.md" aria-label="License">License</a>
@ -847,41 +859,38 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
<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) {
html.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
sunIcon.classList.add('d-none');
moonIcon.classList.remove('d-none');
} else {
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
sunIcon.classList.remove('d-none');
moonIcon.classList.add('d-none');
}
if (typeof loadLottieAnimation === 'function') {
const newTheme = toggle.checked ? 'dark' : 'light'; const newTheme = toggle.checked ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
sunIcon.classList.toggle('d-none', toggle.checked);
moonIcon.classList.toggle('d-none', !toggle.checked);
document.cookie = `theme=${newTheme}; path=/; max-age=31536000`;
if (typeof loadLottieAnimation === 'function') {
loadLottieAnimation(newTheme); loadLottieAnimation(newTheme);
} }
// Set cookie to persist theme
document.cookie = `theme=${toggle.checked ? 'dark' : 'light'}; path=/; max-age=31536000`;
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme') || (document.cookie.includes('theme=dark') ? 'dark' : 'light'); const savedTheme = localStorage.getItem('theme') || getCookie('theme') || 'light';
const themeToggle = document.getElementById('themeToggle'); const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun'); const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon'); const moonIcon = document.querySelector('.fa-moon');
if (savedTheme === 'dark' && themeToggle) { document.documentElement.setAttribute('data-theme', savedTheme);
document.documentElement.setAttribute('data-theme', 'dark'); themeToggle.checked = savedTheme === 'dark';
themeToggle.checked = true; sunIcon.classList.toggle('d-none', savedTheme === 'dark');
sunIcon.classList.add('d-none'); moonIcon.classList.toggle('d-none', savedTheme !== 'dark');
moonIcon.classList.remove('d-none');
}
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(t => new bootstrap.Tooltip(t)); tooltips.forEach(t => new bootstrap.Tooltip(t));
}); });

234
templates/config_hapi.html Normal file
View File

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

View File

@ -10,7 +10,7 @@
</p> </p>
<!-----------------------------------------------------------------remove the buttons----------------------------------------------------- <!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center"> <div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('import_ig') }}" class="btn btn-primary btn-lg px-4 gap-3">Import IGs</a> <a href="{{ url_for('search_and_import') }}" class="btn btn-primary btn-lg px-4 gap-3">Import IGs</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4 disabled">Manage FHIR Packages</a> <a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4 disabled">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a> <a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
</div> </div>
@ -34,7 +34,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2> <h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
<div> <div>
<a href="{{ url_for('import_ig') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a> <a href="{{ url_for('search_and_import') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{# Import form helpers if needed, e.g., for CSRF token #} {# Import form helpers for CSRF token and field rendering #}
{% from "_form_helpers.html" import render_field %} {% from "_form_helpers.html" import render_field %}
{% block content %} {% block content %}
@ -48,11 +48,10 @@
<p class="text-muted">No packages downloaded yet. Use the "Import IG" tab.</p> <p class="text-muted">No packages downloaded yet. Use the "Import IG" tab.</p>
{% endif %} {% endif %}
{# --- MOVED: Push Response Area --- #} {# Push Response Area #}
<div class="mt-4"> <div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h4><i class="bi bi-file-earmark-text me-2"></i>Push Report</h4> <h4><i class="bi bi-file-earmark-text me-2"></i>Push Report</h4>
{# --- NEW: Report Action Buttons --- #}
<div id="reportActions" style="display: none;"> <div id="reportActions" style="display: none;">
<button id="copyReportBtn" class="btn btn-sm btn-outline-secondary me-2" title="Copy Report Text"> <button id="copyReportBtn" class="btn btn-sm btn-outline-secondary me-2" title="Copy Report Text">
<i class="bi bi-clipboard"></i> Copy <i class="bi bi-clipboard"></i> Copy
@ -61,11 +60,9 @@
<i class="bi bi-download"></i> Download <i class="bi bi-download"></i> Download
</button> </button>
</div> </div>
{# --- END NEW --- #}
</div> </div>
<div id="pushResponse" class="border p-3 rounded bg-light" style="min-height: 100px;"> <div id="pushResponse" class="border p-3 rounded bg-light" style="min-height: 100px;">
<span class="text-muted">Report summary will appear here after pushing...</span> <span class="text-muted">Report summary will appear here after pushing...</span>
{# Flash messages can still appear here if needed #}
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
@ -78,15 +75,13 @@
{% endwith %} {% endwith %}
</div> </div>
</div> </div>
{# --- END MOVED --- #}
</div>{# End Left Column #} </div>{# End Left Column #}
{# Right Column: Push IGs Form and Console #} {# Right Column: Push IGs Form and Console #}
<div class="col-md-6"> <div class="col-md-6">
<h2><i class="bi bi-upload me-2"></i>Push IGs to FHIR Server</h2> <h2><i class="bi bi-upload me-2"></i>Push IGs to FHIR Server</h2>
<form id="pushIgForm"> <form id="pushIgForm">
{{ form.csrf_token if form else '' }} {# Use form passed from route #} {{ form.csrf_token if form else '' }}
{# Package Selection #} {# Package Selection #}
<div class="mb-3"> <div class="mb-3">
@ -111,21 +106,31 @@
<input type="url" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., http://localhost:8080/fhir" required> <input type="url" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., http://localhost:8080/fhir" required>
</div> </div>
{# --- RESTRUCTURED: Auth and Checkboxes --- #} {# Authentication Section #}
<div class="row g-3 mb-3 align-items-end"> <div class="row g-3 mb-3 align-items-end">
{# Authentication Dropdown & Token Input #}
<div class="col-md-5"> <div class="col-md-5">
<label for="authType" class="form-label">Authentication</label> <label for="authType" class="form-label">Authentication</label>
<select class="form-select" id="authType" name="auth_type"> <select class="form-select" id="authType" name="auth_type">
<option value="none" selected>None</option> <option value="none" selected>None</option>
<option value="apiKey">Toolkit API Key (Internal)</option> <option value="apiKey">Toolkit API Key (Internal)</option>
<option value="bearerToken">Bearer Token</option> <option value="bearerToken">Bearer Token</option>
<option value="basic">Basic Authentication</option>
</select> </select>
</div> </div>
<div class="col-md-7" id="authTokenGroup" style="display: none;"> <div class="col-md-7" id="authInputsGroup" style="display: none;">
{# Bearer Token Input #}
<div id="bearerTokenInput" style="display: none;">
<label for="authToken" class="form-label">Bearer Token</label> <label for="authToken" class="form-label">Bearer Token</label>
<input type="password" class="form-control" id="authToken" name="auth_token" placeholder="Enter Bearer Token"> <input type="password" class="form-control" id="authToken" name="auth_token" placeholder="Enter Bearer Token">
</div> </div>
{# Basic Auth Inputs #}
<div id="basicAuthInputs" style="display: none;">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Basic Auth Username">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Basic Auth Password">
</div>
</div>
</div> </div>
{# Checkboxes Row #} {# Checkboxes Row #}
@ -154,12 +159,10 @@
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="verbose" name="verbose"> <input type="checkbox" class="form-check-input" id="verbose" name="verbose">
<label class="form-check-label" for="verbose">Verbose Log</label> <label class="form-check-label" for="verbose">Verbose Log</label>
<small class="form-text text-muted d-block">Show detailed Log.</small> <small class="form-text text-muted d-block">Show detailed log.</small>
</div> </div>
</div> </div>
</div> </div>
{# --- END RESTRUCTURED --- #}
{# Resource Type Filter #} {# Resource Type Filter #}
<div class="mb-3"> <div class="mb-3">
@ -185,7 +188,6 @@
<span class="text-muted">Console output will appear here...</span> <span class="text-muted">Console output will appear here...</span>
</div> </div>
</div> </div>
</div> {# End Right Column #} </div> {# End Right Column #}
</div> {# End row #} </div> {# End row #}
</div> {# End container-fluid #} </div> {# End container-fluid #}
@ -198,13 +200,17 @@ document.addEventListener('DOMContentLoaded', function() {
const pushIgForm = document.getElementById('pushIgForm'); const pushIgForm = document.getElementById('pushIgForm');
const pushButton = document.getElementById('pushButton'); const pushButton = document.getElementById('pushButton');
const liveConsole = document.getElementById('liveConsole'); const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('pushResponse'); // Area for final report const responseDiv = document.getElementById('pushResponse');
const reportActions = document.getElementById('reportActions'); // Container for report buttons const reportActions = document.getElementById('reportActions');
const copyReportBtn = document.getElementById('copyReportBtn'); // New copy button const copyReportBtn = document.getElementById('copyReportBtn');
const downloadReportBtn = document.getElementById('downloadReportBtn'); // New download button const downloadReportBtn = document.getElementById('downloadReportBtn');
const authTypeSelect = document.getElementById('authType'); const authTypeSelect = document.getElementById('authType');
const authTokenGroup = document.getElementById('authTokenGroup'); const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerTokenInput');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const authTokenInput = document.getElementById('authToken'); const authTokenInput = document.getElementById('authToken');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const resourceTypesFilterInput = document.getElementById('resourceTypesFilter'); const resourceTypesFilterInput = document.getElementById('resourceTypesFilter');
const skipFilesFilterInput = document.getElementById('skipFilesFilter'); const skipFilesFilterInput = document.getElementById('skipFilesFilter');
const dryRunCheckbox = document.getElementById('dryRun'); const dryRunCheckbox = document.getElementById('dryRun');
@ -226,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (packageSelect && dependencyModeField) { if (packageSelect && dependencyModeField) {
packageSelect.addEventListener('change', function() { packageSelect.addEventListener('change', function() {
const packageId = this.value; const packageId = this.value;
dependencyModeField.value = ''; // Clear on change dependencyModeField.value = '';
if (packageId) { if (packageId) {
const [packageName, version] = packageId.split('#'); const [packageName, version] = packageId.split('#');
fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`) fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`)
@ -240,24 +246,32 @@ document.addEventListener('DOMContentLoaded', function() {
if (packageSelect.value) { packageSelect.dispatchEvent(new Event('change')); } if (packageSelect.value) { packageSelect.dispatchEvent(new Event('change')); }
} }
// Show/Hide Bearer Token Input // Show/Hide Auth Inputs
if (authTypeSelect && authTokenGroup) { if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
authTypeSelect.addEventListener('change', function() { authTypeSelect.addEventListener('change', function() {
authTokenGroup.style.display = this.value === 'bearerToken' ? 'block' : 'none'; authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
// Clear inputs when switching
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = ''; if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
}); });
authTokenGroup.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none'; authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
} else {
console.error("Auth elements not found.");
} }
// --- NEW: Report Action Button Listeners --- // Report Action Button Listeners
if (copyReportBtn && responseDiv) { if (copyReportBtn && responseDiv) {
copyReportBtn.addEventListener('click', () => { copyReportBtn.addEventListener('click', () => {
const reportAlert = responseDiv.querySelector('.alert'); // Get the alert div inside const reportAlert = responseDiv.querySelector('.alert');
const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : ''; // Get text content const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : '';
if (reportText && navigator.clipboard) { if (reportText && navigator.clipboard) {
navigator.clipboard.writeText(reportText) navigator.clipboard.writeText(reportText)
.then(() => { .then(() => {
// Optional: Provide feedback (e.g., change button text/icon)
const originalIcon = copyReportBtn.innerHTML; const originalIcon = copyReportBtn.innerHTML;
copyReportBtn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!'; copyReportBtn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
setTimeout(() => { copyReportBtn.innerHTML = originalIcon; }, 2000); setTimeout(() => { copyReportBtn.innerHTML = originalIcon; }, 2000);
@ -292,27 +306,27 @@ document.addEventListener('DOMContentLoaded', function() {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up URL.revokeObjectURL(url);
} else { } else {
alert('No report content found to download.'); alert('No report content found to download.');
} }
}); });
} }
// --- END NEW ---
// Form Submission
// --- Form Submission ---
if (pushIgForm) { if (pushIgForm) {
pushIgForm.addEventListener('submit', async function(event) { pushIgForm.addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
// Get form values (with null checks for elements) // Get form values
const packageId = packageSelect ? packageSelect.value : null; const packageId = packageSelect ? packageSelect.value : null;
const fhirServerUrl = fhirServerUrlInput ? fhirServerUrlInput.value.trim() : null; const fhirServerUrl = fhirServerUrlInput ? fhirServerUrlInput.value.trim() : null;
if (!packageId || !fhirServerUrl) { alert('Please select package and enter FHIR Server URL.'); return; } if (!packageId || !fhirServerUrl) { alert('Please select package and enter FHIR Server URL.'); return; }
const [packageName, version] = packageId.split('#'); const [packageName, version] = packageId.split('#');
const auth_type = authTypeSelect ? authTypeSelect.value : 'none'; const auth_type = authTypeSelect ? authTypeSelect.value : 'none';
const auth_token = (auth_type === 'bearerToken' && authTokenInput) ? authTokenInput.value : null; const auth_token = (auth_type === 'bearerToken' && authTokenInput) ? authTokenInput.value : null;
const username = (auth_type === 'basic' && usernameInput) ? usernameInput.value.trim() : null;
const password = (auth_type === 'basic' && passwordInput) ? passwordInput.value : null;
const resource_types_filter_raw = resourceTypesFilterInput ? resourceTypesFilterInput.value.trim() : ''; const resource_types_filter_raw = resourceTypesFilterInput ? resourceTypesFilterInput.value.trim() : '';
const resource_types_filter = resource_types_filter_raw ? resource_types_filter_raw.split(',').map(s => s.trim()).filter(s => s) : null; const resource_types_filter = resource_types_filter_raw ? resource_types_filter_raw.split(',').map(s => s.trim()).filter(s => s) : null;
const skip_files_raw = skipFilesFilterInput ? skipFilesFilterInput.value.trim() : ''; const skip_files_raw = skipFilesFilterInput ? skipFilesFilterInput.value.trim() : '';
@ -322,12 +336,23 @@ document.addEventListener('DOMContentLoaded', function() {
const include_dependencies = includeDependenciesCheckbox ? includeDependenciesCheckbox.checked : true; const include_dependencies = includeDependenciesCheckbox ? includeDependenciesCheckbox.checked : true;
const force_upload = forceUploadCheckbox ? forceUploadCheckbox.checked : false; const force_upload = forceUploadCheckbox ? forceUploadCheckbox.checked : false;
// UI Updates & API Key // Validate Basic Auth inputs
if (pushButton) { pushButton.disabled = true; pushButton.textContent = 'Processing...'; } if (auth_type === 'basic') {
if (liveConsole) { liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting ${dry_run ? 'DRY RUN ' : ''}${force_upload ? 'FORCE ' : ''}push for ${packageName}#${version}...</div>`; } if (!username) { alert('Please enter a username for Basic Authentication.'); return; }
if (responseDiv) { responseDiv.innerHTML = '<span class="text-muted">Processing...</span>'; } // Clear previous report if (!password) { alert('Please enter a password for Basic Authentication.'); return; }
if (reportActions) { reportActions.style.display = 'none'; } // Hide report buttons initially }
const internalApiKey = {{ api_key | default("") | tojson }}; // Use tojson filter
// UI Updates
if (pushButton) {
pushButton.disabled = true;
pushButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Processing...';
}
if (liveConsole) {
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting ${dry_run ? 'DRY RUN ' : ''}${force_upload ? 'FORCE ' : ''}push for ${packageName}#${version}...</div>`;
}
if (responseDiv) { responseDiv.innerHTML = '<span class="text-muted">Processing...</span>'; }
if (reportActions) { reportActions.style.display = 'none'; }
const internalApiKey = {{ api_key | default("") | tojson }};
try { try {
// API Fetch // API Fetch
@ -335,10 +360,19 @@ document.addEventListener('DOMContentLoaded', function() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
body: JSON.stringify({ body: JSON.stringify({
package_name: packageName, version: version, fhir_server_url: fhirServerUrl, package_name: packageName,
include_dependencies: include_dependencies, auth_type: auth_type, auth_token: auth_token, version: version,
resource_types_filter: resource_types_filter, skip_files: skip_files, fhir_server_url: fhirServerUrl,
dry_run: dry_run, verbose: isVerboseChecked, force_upload: force_upload include_dependencies: include_dependencies,
auth_type: auth_type,
auth_token: auth_token,
username: username,
password: password,
resource_types_filter: resource_types_filter,
skip_files: skip_files,
dry_run: dry_run,
verbose: isVerboseChecked,
force_upload: force_upload
}) })
}); });
@ -361,25 +395,28 @@ document.addEventListener('DOMContentLoaded', function() {
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString(); const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
let messageClass = 'text-light'; let prefix = '[INFO]'; let shouldDisplay = false; let messageClass = 'text-light'; let prefix = '[INFO]'; let shouldDisplay = false;
switch (data.type) { // Determine if message should display based on verbose switch (data.type) {
case 'start': case 'error': case 'complete': shouldDisplay = true; break; case 'start': case 'error': case 'complete': shouldDisplay = true; break;
case 'success': case 'warning': case 'info': case 'progress': if (isVerboseChecked) { shouldDisplay = true; } break; case 'success': case 'warning': case 'info': case 'progress': if (isVerboseChecked) { shouldDisplay = true; } break;
default: if (isVerboseChecked) { shouldDisplay = true; console.warn("Unknown type:", data.type); prefix = '[UNKNOWN]'; } break; default: if (isVerboseChecked) { shouldDisplay = true; console.warn("Unknown type:", data.type); prefix = '[UNKNOWN]'; } break;
} }
if (shouldDisplay && liveConsole) { // Set prefix/class and append to console if (shouldDisplay && liveConsole) {
if(data.type==='error'){prefix='[ERROR]';messageClass='text-danger';} if(data.type==='error'){prefix='[ERROR]';messageClass='text-danger';}
else if(data.type==='complete'){const s=data.data?.status||'info';if(s==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(s==='partial'){prefix='[PARTIAL]';messageClass='text-warning';}else{prefix='[ERROR]';messageClass='text-danger';}} else if(data.type==='complete'){const s=data.data?.status||'info';if(s==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(s==='partial'){prefix='[PARTIAL]';messageClass='text-warning';}else{prefix='[ERROR]';messageClass='text-danger';}}
else if(data.type==='start'){prefix='[START]';messageClass='text-info';} else if(data.type==='start'){prefix='[START]';messageClass='text-info';}
else if(data.type==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(data.type==='warning'){prefix='[WARNING]';messageClass='text-warning';}else if(data.type==='info'){prefix='[INFO]';messageClass='text-info';}else{prefix='[PROGRESS]';messageClass='text-light';} else if(data.type==='success'){prefix='[SUCCESS]';messageClass='text-success';}
else if(data.type==='warning'){prefix='[WARNING]';messageClass='text-warning';}
else if(data.type==='info'){prefix='[INFO]';messageClass='text-info';}
else{prefix='[PROGRESS]';messageClass='text-light';}
const messageDiv = document.createElement('div'); messageDiv.className = messageClass; const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message; const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message;
messageDiv.textContent = `${timestamp} ${prefix} ${sanitizeText(messageText) || 'Empty message.'}`; messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(messageText) || 'Empty message.'}`;
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight; liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
} }
if (data.type === 'complete' && responseDiv) { // Update final summary box if (data.type === 'complete' && responseDiv) {
const summaryData = data.data || {}; let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = ''; const summaryData = data.data || {}; let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false; const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All'; const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
@ -391,28 +428,74 @@ document.addEventListener('DOMContentLoaded', function() {
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';} if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`; responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
if (reportActions) { reportActions.style.display = 'block'; } // Show report buttons if (reportActions) { reportActions.style.display = 'block'; }
}
} catch (parseError) {
console.error('Stream parse error:', parseError);
if (liveConsole) {
const errDiv = document.createElement('div');
errDiv.className = 'text-danger';
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Stream parse error: ${sanitizeText(parseError.message)}`;
liveConsole.appendChild(errDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
}
}
}
} }
} catch (parseError) { /* (Handle JSON parse errors) */ console.error('Stream parse error:', parseError); if(liveConsole){/*...add error to console...*/} }
} // end for loop
} // end while loop
// Process Final Buffer (if any) // Process Final Buffer
if (buffer.trim()) { if (buffer.trim()) {
try { /* (Parsing logic for final buffer, similar to above) */ } try {
catch (parseError) { /* (Handle final buffer parse error) */ } const data = JSON.parse(buffer.trim());
if (data.type === 'complete' && responseDiv) {
// Same summary rendering as above
const summaryData = data.data || {};
let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
const fileFilterUsed = summaryData.skip_files_filter ? summaryData.skip_files_filter.join(', ') : 'None';
if (summaryData.pushed_packages_summary?.length > 0) { pushedPkgs = summaryData.pushed_packages_summary.map(p => `${sanitizeText(p.id)} (${sanitizeText(p.resource_count)} resources)`).join('<br>'); }
if (summaryData.status === 'success') { alertClass = 'alert-success'; statusText = 'Success';} else if (summaryData.status === 'partial') { alertClass = 'alert-warning'; statusText = 'Partial Success'; } else { alertClass = 'alert-danger'; statusText = 'Error'; }
if (summaryData.failed_details?.length > 0) { failHtml = '<hr><strong>Failures:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.failed_details.forEach(f => {failHtml += `<li><strong>${sanitizeText(f.resource)}:</strong> ${sanitizeText(f.error)}</li>`;}); failHtml += '</ul>';}
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
if (reportActions) { reportActions.style.display = 'block'; }
}
} catch (parseError) {
console.error('Final buffer parse error:', parseError);
if (liveConsole) {
const errDiv = document.createElement('div');
errDiv.className = 'text-danger';
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Final buffer parse error: ${sanitizeText(parseError.message)}`;
liveConsole.appendChild(errDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
}
}
} }
} catch (error) { // Handle overall fetch/network errors } catch (error) {
console.error("Push operation failed:", error); console.error("Push operation failed:", error);
if (liveConsole) { /* ... add error to console ... */ } if (liveConsole) {
if (responseDiv) { responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message || error)}</div>`; } const errDiv = document.createElement('div');
if (reportActions) { reportActions.style.display = 'none'; } // Hide buttons on error errDiv.className = 'text-danger';
} finally { // Re-enable button errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] ${sanitizeText(error.message || error)}`;
if (pushButton) { pushButton.disabled = false; pushButton.textContent = 'Push to FHIR Server'; } liveConsole.appendChild(errDiv);
liveConsole.scrollTop = liveConsole.scrollHeight;
} }
}); // End form submit listener if (responseDiv) {
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message || error)}</div>`;
}
if (reportActions) { reportActions.style.display = 'none'; }
} finally {
if (pushButton) {
pushButton.disabled = false;
pushButton.innerHTML = '<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server';
}
}
});
} else { console.error("Push IG Form element not found."); } } else { console.error("Push IG Form element not found."); }
}); // End DOMContentLoaded listener });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -8,13 +8,6 @@
<p class="lead mb-4"> <p class="lead mb-4">
View details of the processed FHIR Implementation Guide. View details of the processed FHIR Implementation Guide.
</p> </p>
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
</div>
----------------------------------------------------------------remove the buttons----------------------------------------------------------------------------->
</div> </div>
</div> </div>
@ -269,7 +262,7 @@
<div id="raw-structure-wrapper" class="mt-4" style="display: none;"> <div id="raw-structure-wrapper" class="mt-4" style="display: none;">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-itemsitum-center">
<span>Raw Structure Definition for <code id="raw-structure-title"></code></span> <span>Raw Structure Definition for <code id="raw-structure-title"></code></span>
<button type="button" class="btn btn-sm btn-outline-secondary btn-copy" id="copy-raw-def-button" <button type="button" class="btn btn-sm btn-outline-secondary btn-copy" id="copy-raw-def-button"
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Definition JSON"> data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Definition JSON">
@ -285,7 +278,6 @@
</div> </div>
</div> </div>
</div> </div>
<div id="example-display-wrapper" class="mt-4" style="display: none;"> <div id="example-display-wrapper" class="mt-4" style="display: none;">
<div class="card"> <div class="card">
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div> <div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
@ -298,6 +290,10 @@
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div> <div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
</div> </div>
<div id="example-content-wrapper" style="display: none;"> <div id="example-content-wrapper" style="display: none;">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="includeNarrative" checked>
<label class="form-check-label" for="includeNarrative">Include Narrative</label>
</div>
<h6 id="example-filename" class="mt-2 small text-muted"></h6> <h6 id="example-filename" class="mt-2 small text-muted"></h6>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@ -352,6 +348,7 @@ let copyRawDefTooltipInstance = null;
let copyRawExTooltipInstance = null; let copyRawExTooltipInstance = null;
let copyPrettyJsonTooltipInstance = null; let copyPrettyJsonTooltipInstance = null;
let structureDataCache = {}; // Cache for fetched structure data let structureDataCache = {}; // Cache for fetched structure data
let includeNarrativeCheckbox; // New global for narrative toggle
// --- Global Helper Functions --- // --- Global Helper Functions ---
function parseFhirPath(path) { function parseFhirPath(path) {
@ -379,14 +376,12 @@ function getNodeKey(element) {
const baseKey = (element.id && element.id.includes('.')) ? element.id : element.path; const baseKey = (element.id && element.id.includes('.')) ? element.id : element.path;
if (!baseKey) { if (!baseKey) {
console.warn("Cannot generate key: element missing structured id and path", element); console.warn("Cannot generate key: element missing structured id and path", element);
// Provide a fallback key if absolutely necessary, though this indicates data issues
return `no-key-${Math.random()}`; return `no-key-${Math.random()}`;
} }
// Append sliceName if present to ensure uniqueness for slices // Append sliceName if present to ensure uniqueness for slices
return element.sliceName ? `${baseKey}::slice::${element.sliceName}` : baseKey; return element.sliceName ? `${baseKey}::slice::${element.sliceName}` : baseKey;
} }
// Helper function to initialize a node // Helper function to initialize a node
function createNode(path, name, parentPathKey, element = null) { function createNode(path, name, parentPathKey, element = null) {
// Determine flags based on element data passed from backend // Determine flags based on element data passed from backend
@ -458,9 +453,6 @@ function flagSlicingEntriesWithMSSlices(node) {
// Flag THIS node if it defines slicing AND (a direct child is an MS slice OR a deeper descendant is an MS slice) // Flag THIS node if it defines slicing AND (a direct child is an MS slice OR a deeper descendant is an MS slice)
if (node.element?.slicing && (directChildIsMSSlice || containedMSSliceInChildren)) { if (node.element?.slicing && (directChildIsMSSlice || containedMSSliceInChildren)) {
node.containsMustSupportSlice = true; node.containsMustSupportSlice = true;
// --- DEBUG LOG ---
// console.log(`>>> Flagged Slicing Entry ${node.path} as containsMustSupportSlice = true (DirectChild: ${directChildIsMSSlice}, Descendant: ${containedMSSliceInChildren})`);
// --- END DEBUG LOG ---
} }
// Return true if THIS node is an MS slice OR if it was flagged as containing one below it // Return true if THIS node is an MS slice OR if it was flagged as containing one below it
@ -468,7 +460,6 @@ function flagSlicingEntriesWithMSSlices(node) {
return isMustSupportSlice || node.containsMustSupportSlice === true; return isMustSupportSlice || node.containsMustSupportSlice === true;
} // End flagSlicingEntriesWithMSSlices V2 } // End flagSlicingEntriesWithMSSlices V2
// --- Build Tree Data - V13 (Correct Parent Finding for Nested Slices) --- // --- Build Tree Data - V13 (Correct Parent Finding for Nested Slices) ---
function buildTreeData(elements, view, mustSupportPaths) { function buildTreeData(elements, view, mustSupportPaths) {
const treeData = []; const treeData = [];
@ -524,13 +515,6 @@ function buildTreeData(elements, view, mustSupportPaths) {
const nodeKey = getNodeKey(element); const nodeKey = getNodeKey(element);
const isSlice = !!element.sliceName; const isSlice = !!element.sliceName;
// Optional Debug Log
const debugPaths = ['Observation.component', 'Observation.component.code', 'Observation.component.code.coding'];
const shouldDebug = debugPaths.some(p => path?.startsWith(p));
// if (shouldDebug) { // Keep this commented out unless actively debugging parent finding
// console.log(`[Debug] Processing element ${index}: Path='${path}', SliceName='${element.sliceName}', ID='${id}', Key='${nodeKey}'`);
// }
if (!path || !nodeKey) { console.warn(`Skipping element ${index} with missing path/key`, element); return; } if (!path || !nodeKey) { console.warn(`Skipping element ${index} with missing path/key`, element); return; }
// Root element handling (Keep V12 logic) // Root element handling (Keep V12 logic)
@ -558,12 +542,9 @@ function buildTreeData(elements, view, mustSupportPaths) {
// Derive the expected parent ID/Path from the slice's ID or Path // Derive the expected parent ID/Path from the slice's ID or Path
if (id && id.includes('.')) { if (id && id.includes('.')) {
// Try deriving from ID first (more specific) // Try deriving from ID first (more specific)
// Example: id = Observation.component:DiastolicBP.code.coding:DBPCode
// We want the parent ID = Observation.component:DiastolicBP.code.coding
const idParts = id.split(':'); const idParts = id.split(':');
if (idParts.length > 1) { if (idParts.length > 1) {
const parentIdGuess = idParts.slice(0, -1).join(':'); const parentIdGuess = idParts.slice(0, -1).join(':');
// Check if the parent ID ends with a path segment (contains '.')
if (parentIdGuess.includes('.')) { if (parentIdGuess.includes('.')) {
expectedParentId = parentIdGuess; expectedParentId = parentIdGuess;
} }
@ -572,7 +553,6 @@ function buildTreeData(elements, view, mustSupportPaths) {
if (!expectedParentId && path && path.includes('.')) { if (!expectedParentId && path && path.includes('.')) {
expectedParentPath = path; // The base path is the parent slicing entry path expectedParentPath = path; // The base path is the parent slicing entry path
} }
} else if (path && path.includes('.')) { } else if (path && path.includes('.')) {
// If no complex ID, use the path // If no complex ID, use the path
expectedParentPath = path; expectedParentPath = path;
@ -593,7 +573,6 @@ function buildTreeData(elements, view, mustSupportPaths) {
parentElement = slicingEntryElement; parentElement = slicingEntryElement;
parentNodeKey = getNodeKey(parentElement); parentNodeKey = getNodeKey(parentElement);
parentFound = true; parentFound = true;
// console.log(`[Debug] Parent Check 1 (Slice V13): ${nodeKey} -> ${parentNodeKey} (ParentID: ${expectedParentId}, ParentPath: ${expectedParentPath})`);
} else { } else {
console.warn(`Slicing entry element NOT FOUND for expected parent (ID: ${expectedParentId}, Path: ${expectedParentPath}) needed by slice '${nodeKey}'. Assigning root.`); console.warn(`Slicing entry element NOT FOUND for expected parent (ID: ${expectedParentId}, Path: ${expectedParentPath}) needed by slice '${nodeKey}'. Assigning root.`);
parentElement = rootElementData; parentElement = rootElementData;
@ -601,7 +580,7 @@ function buildTreeData(elements, view, mustSupportPaths) {
parentFound = true; // Consider parent determined (fallback) parentFound = true; // Consider parent determined (fallback)
} }
} }
// Check 2: Descendant of a Slice (using ID structure) (Keep V12 logic - seems okay from logs) // Check 2: Descendant of a Slice (using ID structure) (Keep V12 logic)
else if (id && id.includes(':') && id.includes('.')) { else if (id && id.includes(':') && id.includes('.')) {
const idParts = id.split('.'); const idParts = id.split('.');
let potentialSliceParentId = null; let potentialSliceParentId = null;
@ -615,13 +594,11 @@ function buildTreeData(elements, view, mustSupportPaths) {
const potentialParentNode = nodeMap.get(potentialParentKey); const potentialParentNode = nodeMap.get(potentialParentKey);
if (potentialParentNode?.element) { if (potentialParentNode?.element) {
parentNodeKey = potentialParentKey; parentElement = potentialParentNode.element; parentFound = true; parentNodeKey = potentialParentKey; parentElement = potentialParentNode.element; parentFound = true;
// console.log(`[Debug] Parent Check 2 (Slice Descendant ID V13): ${nodeKey} -> ${parentNodeKey} (via Map Key ${potentialParentKey})`);
} else { console.warn(`Node found in map for key ${potentialParentKey} but its element was missing. Falling back.`); } } else { console.warn(`Node found in map for key ${potentialParentKey} but its element was missing. Falling back.`); }
} else { } else {
const sliceParentElement = elements.find(el => el.id === potentialSliceParentId); const sliceParentElement = elements.find(el => el.id === potentialSliceParentId);
if (sliceParentElement) { if (sliceParentElement) {
parentNodeKey = getNodeKey(sliceParentElement); parentElement = sliceParentElement; parentFound = true; parentNodeKey = getNodeKey(sliceParentElement); parentElement = sliceParentElement; parentFound = true;
// console.log(`[Debug] Parent Check 2 (Slice Descendant ID V13): ${nodeKey} -> ${parentNodeKey} (via Element ID ${potentialSliceParentId})`);
} else { console.warn(`Slice parent element ID '${potentialSliceParentId}' not found for descendant '${id}'. Falling back.`); } } else { console.warn(`Slice parent element ID '${potentialSliceParentId}' not found for descendant '${id}'. Falling back.`); }
} }
} }
@ -642,16 +619,13 @@ function buildTreeData(elements, view, mustSupportPaths) {
parentElement = potentialParentNode.element; parentElement = potentialParentNode.element;
parentNodeKey = getNodeKey(parentElement); parentNodeKey = getNodeKey(parentElement);
parentFound = true; parentFound = true;
// console.log(`[Debug] Parent Check 3/4 (Path V13): ${nodeKey} -> ${parentNodeKey}`);
} else { } else {
parentElement = rootElementData; parentNodeKey = getNodeKey(parentElement); parentElement = rootElementData; parentNodeKey = getNodeKey(parentElement);
parentFound = true; parentFound = true;
// console.warn(`[Debug] Parent Check 3/4 (Path V13): Parent node NOT FOUND for path '${parentPathString}' needed by '${nodeKey}'. Assigning root.`);
} }
} else { } else {
parentElement = rootElementData; parentNodeKey = getNodeKey(parentElement); parentElement = rootElementData; parentNodeKey = getNodeKey(parentElement);
parentFound = true; parentFound = true;
// console.log(`[Debug] Parent Check 3/4 (Path V13): Parent is root for ${nodeKey}`);
} }
} }
// --- End Parent Finding Logic --- // --- End Parent Finding Logic ---
@ -683,7 +657,6 @@ function buildTreeData(elements, view, mustSupportPaths) {
if (parentNode) { if (parentNode) {
if (!parentNode.children) parentNode.children = []; if (!parentNode.children) parentNode.children = [];
parentNode.children.push(newNode); parentNode.children.push(newNode);
// console.log(`[Debug] Attaching new node ${nodeKey} to parent ${parentNodeKey}`);
} else { } else {
console.error(`Cannot add child ${nodeKey} as parent node object is unexpectedly null.`); console.error(`Cannot add child ${nodeKey} as parent node object is unexpectedly null.`);
} }
@ -727,12 +700,8 @@ function buildTreeData(elements, view, mustSupportPaths) {
return treeData; return treeData;
} // End buildTreeData V13 } // End buildTreeData V13
// --- Render Node as List Item - V19 (Fixes isSlice error, keeps V18 logic) --- // --- Render Node as List Item - V19 (Fixes isSlice error, keeps V18 logic) ---
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0, view = 'snapshot') { function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0, view = 'snapshot') {
// --- Constants for Primitive Types ---
// REMOVED PRIMITIVE_TYPES check to align with reference rendering
if (!node || !node.path || !node.name) { console.warn(`Skipping render for invalid node:`, node); return ''; } if (!node || !node.path || !node.name) { console.warn(`Skipping render for invalid node:`, node); return ''; }
const el = node.element || {}; const el = node.element || {};
const path = el.path || node.path || `node-${level}-${Math.random()}`; const path = el.path || node.path || `node-${level}-${Math.random()}`;
@ -758,8 +727,6 @@ function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0, view
mustSupportTitle = `Must Support (Slice: ${sliceName})`; mustSupportTitle = `Must Support (Slice: ${sliceName})`;
fhirPathExpression = `${path}[sliceName='${sliceName}']`; fhirPathExpression = `${path}[sliceName='${sliceName}']`;
} }
// Optional Debug Log for highlighting
// if (node.path === 'Patient.extension' || node.path === 'Patient.identifier') { console.log(...) }
const msIconClass = (isMustSupport || node.containsMustSupportSlice) ? 'text-warning' : ''; const msIconClass = (isMustSupport || node.containsMustSupportSlice) ? 'text-warning' : '';
const mustSupportDisplay = (isMustSupport || node.containsMustSupportSlice) ? `<i class="bi bi-check-circle-fill ${msIconClass} ms-1" title="${escapeHtmlAttr(mustSupportTitle)}" data-bs-toggle="tooltip" data-fhirpath="${escapeHtmlAttr(fhirPathExpression)}"></i>` : ''; const mustSupportDisplay = (isMustSupport || node.containsMustSupportSlice) ? `<i class="bi bi-check-circle-fill ${msIconClass} ms-1" title="${escapeHtmlAttr(mustSupportTitle)}" data-bs-toggle="tooltip" data-fhirpath="${escapeHtmlAttr(fhirPathExpression)}"></i>` : '';
@ -798,7 +765,6 @@ function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0, view
} else if (el.contentReference) { typeString = `<span class="text-info" title="Content Reference">Ref: ${escapeHtmlAttr(el.contentReference)}</span>`; } } else if (el.contentReference) { typeString = `<span class="text-info" title="Content Reference">Ref: ${escapeHtmlAttr(el.contentReference)}</span>`; }
else if (el.sliceName && path.endsWith('extension')) { typeString = '<span class="text-muted">Extension</span>'; } else if (el.sliceName && path.endsWith('extension')) { typeString = '<span class="text-muted">Extension</span>'; }
// Children HTML / Choice Type Display (V18 logic - reverted primitive filtering) // Children HTML / Choice Type Display (V18 logic - reverted primitive filtering)
let childrenOrChoiceHtml = ''; let childrenOrChoiceHtml = '';
if (showToggle) { if (showToggle) {
@ -863,7 +829,6 @@ function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0, view
return itemHtml; return itemHtml;
} // End renderNodeAsLi (V19) } // End renderNodeAsLi (V19)
// --- Function to Initialize Collapse Icon Listeners (Bootstrap Events) V3 - Explicit Cleanup --- // --- Function to Initialize Collapse Icon Listeners (Bootstrap Events) V3 - Explicit Cleanup ---
function initializeBootstrapCollapseListeners(containerElement) { function initializeBootstrapCollapseListeners(containerElement) {
if (!containerElement) { return; } if (!containerElement) { return; }
@ -876,8 +841,8 @@ function initializeBootstrapCollapseListeners(containerElement) {
if (!triggerEl) { console.warn(`Trigger not found for #${collapseId}`); } if (!triggerEl) { console.warn(`Trigger not found for #${collapseId}`); }
if (collapseEl._handleShow && typeof collapseEl._handleShow === 'function') { collapseEl.removeEventListener('show.bs.collapse', collapseEl._handleShow); } if (collapseEl._handleShow && typeof collapseEl._handleShow === 'function') { collapseEl.removeEventListener('show.bs.collapse', collapseEl._handleShow); }
if (collapseEl._handleHide && typeof collapseEl._handleHide === 'function') { collapseEl.removeEventListener('hide.bs.collapse', collapseEl._handleHide); } if (collapseEl._handleHide && typeof collapseEl._handleHide === 'function') { collapseEl.removeEventListener('hide.bs.collapse', collapseEl._handleHide); }
const handleShow = (event) => { if (event.target === collapseEl && icon) { icon.classList.replace('bi-chevron-right', 'bi-chevron-down'); } /* console.log(...) */ }; const handleShow = (event) => { if (event.target === collapseEl && icon) { icon.classList.replace('bi-chevron-right', 'bi-chevron-down'); } };
const handleHide = (event) => { if (event.target === collapseEl && icon) { icon.classList.replace('bi-chevron-down', 'bi-chevron-right'); } /* console.log(...) */ }; const handleHide = (event) => { if (event.target === collapseEl && icon) { icon.classList.replace('bi-chevron-down', 'bi-chevron-right'); } };
collapseEl._handleShow = handleShow; collapseEl._handleHide = handleHide; collapseEl._handleShow = handleShow; collapseEl._handleHide = handleHide;
collapseEl.addEventListener('show.bs.collapse', handleShow); collapseEl.addEventListener('show.bs.collapse', handleShow);
collapseEl.addEventListener('hide.bs.collapse', handleHide); collapseEl.addEventListener('hide.bs.collapse', handleHide);
@ -885,15 +850,14 @@ function initializeBootstrapCollapseListeners(containerElement) {
}); });
} // End initializeBootstrapCollapseListeners V3 } // End initializeBootstrapCollapseListeners V3
// --- Render Structure Tree (Calls V13 buildTreeData, V19 renderNode, V3 initBSCollapse) ---
// --- Render Structure Tree (Calls V11 buildTreeData, V19 renderNode, V3 initBSCollapse) ---
async function renderStructureTree(resourceType, view, snapshotElements, mustSupportPaths) { // Accepts snapshot elements async function renderStructureTree(resourceType, view, snapshotElements, mustSupportPaths) { // Accepts snapshot elements
const treeContainer = document.getElementById(`structure-tree-${view}`); const treeContainer = document.getElementById(`structure-tree-${view}`);
if (!treeContainer) { console.error(`Tree container missing: ${view}`); return; } if (!treeContainer) { console.error(`Tree container missing: ${view}`); return; }
treeContainer.innerHTML = ''; treeContainer.innerHTML = '';
try { try {
// Build tree using V11 buildTreeData and snapshot data // Build tree using V13 buildTreeData and snapshot data
const treeData = buildTreeData(snapshotElements, view, mustSupportPaths); const treeData = buildTreeData(snapshotElements, view, mustSupportPaths);
// Render HTML using V19 renderNodeAsLi // Render HTML using V19 renderNodeAsLi
@ -908,7 +872,13 @@ async function renderStructureTree(resourceType, view, snapshotElements, mustSup
// Init listeners/tooltips // Init listeners/tooltips
initializeBootstrapCollapseListeners(treeContainer); // V3 initializeBootstrapCollapseListeners(treeContainer); // V3
const tooltipTriggerList = treeContainer.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = treeContainer.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(tooltipTriggerEl => { const e=bootstrap.Tooltip.getInstance(tooltipTriggerEl); if(e){e.dispose();} if(tooltipTriggerEl.getAttribute('title')||tooltipTriggerEl.getAttribute('data-bs-original-title')){new bootstrap.Tooltip(tooltipTriggerEl);} }); tooltipTriggerList.forEach(tooltipTriggerEl => {
const e = bootstrap.Tooltip.getInstance(tooltipTriggerEl);
if (e) { e.dispose(); }
if (tooltipTriggerEl.getAttribute('title') || tooltipTriggerEl.getAttribute('data-bs-original-title')) {
new bootstrap.Tooltip(tooltipTriggerEl);
}
});
} catch (error) { } catch (error) {
console.error(`Error rendering ${view} tree:`, error); console.error(`Error rendering ${view} tree:`, error);
@ -916,15 +886,21 @@ async function renderStructureTree(resourceType, view, snapshotElements, mustSup
} }
} // End renderStructureTree } // End renderStructureTree
// --- Fetch Structure Definition for Specific View --- // --- Fetch Structure Definition for Specific View ---
async function fetchStructure(resourceType, view) { async function fetchStructure(resourceType, view, includeNarrative, raw = false) {
const cacheKey = `${resourceType}-${view}`; const cacheKey = `${resourceType}-${view}-${includeNarrative}-${raw}`;
if (structureDataCache[cacheKey]) { if (structureDataCache[cacheKey]) {
try { return JSON.parse(JSON.stringify(structureDataCache[cacheKey])); } catch (e) { return structureDataCache[cacheKey]; } try { return JSON.parse(JSON.stringify(structureDataCache[cacheKey])); } catch (e) { return structureDataCache[cacheKey]; }
} }
const structureBaseUrl = "{{ url_for('get_structure') }}"; const structureBaseUrl = "{{ url_for('get_structure') }}";
const params = new URLSearchParams({ package_name: "{{ processed_ig.package_name }}", package_version: "{{ processed_ig.version }}", resource_type: resourceType, view: view }); const params = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}",
version: "{{ processed_ig.version }}",
resource_type: resourceType,
view: view,
include_narrative: includeNarrative,
raw: raw
});
const fetchUrl = `${structureBaseUrl}?${params.toString()}`; const fetchUrl = `${structureBaseUrl}?${params.toString()}`;
try { try {
const response = await fetch(fetchUrl); const response = await fetch(fetchUrl);
@ -932,13 +908,11 @@ async function fetchStructure(resourceType, view) {
const data = await response.json(); const data = await response.json();
try { structureDataCache[cacheKey] = JSON.parse(JSON.stringify(data)); } catch(e) { structureDataCache[cacheKey] = data; } try { structureDataCache[cacheKey] = JSON.parse(JSON.stringify(data)); } catch(e) { structureDataCache[cacheKey] = data; }
return JSON.parse(JSON.stringify(data)); return JSON.parse(JSON.stringify(data));
} catch (error) { console.error(`Error fetching ${view} structure:`, error); throw error; } } catch (error) { console.error(`Error fetching ${raw ? 'raw' : view} structure:`, error); throw error; }
} }
// --- Populate Structure for All Views - V5 (Add Search Param Logging, Include Narrative) ---
// --- Populate Structure for All Views - V4 (Add Search Param Logging) ---
async function populateStructure(resourceType) { async function populateStructure(resourceType) {
// ... (keep existing variable assignments and UI resets at the beginning) ...
if (structureTitle) structureTitle.textContent = `${resourceType} ({{ processed_ig.package_name }}#{{ processed_ig.version }})`; if (structureTitle) structureTitle.textContent = `${resourceType} ({{ processed_ig.package_name }}#{{ processed_ig.version }})`;
if (rawStructureTitle) rawStructureTitle.textContent = `${resourceType} ({{ processed_ig.package_name }}#{{ processed_ig.version }})`; if (rawStructureTitle) rawStructureTitle.textContent = `${resourceType} ({{ processed_ig.package_name }}#{{ processed_ig.version }})`;
if (structureLoading) structureLoading.style.display = 'block'; if (structureLoading) structureLoading.style.display = 'block';
@ -955,70 +929,51 @@ async function populateStructure(resourceType) {
if (bindingsTableBody) bindingsTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted fst-italic">Loading...</td></tr>'; if (bindingsTableBody) bindingsTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted fst-italic">Loading...</td></tr>';
if (searchParamsTableBody) searchParamsTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted fst-italic">Loading...</td></tr>'; if (searchParamsTableBody) searchParamsTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted fst-italic">Loading...</td></tr>';
if (rawStructureContent) rawStructureContent.textContent = 'Loading...'; if (rawStructureContent) rawStructureContent.textContent = 'Loading...';
try { try {
console.log(`PopulateStructure V4: Fetching structure and search params for ${resourceType}`); console.log(`PopulateStructure V4: Fetching structure and search params for ${resourceType}`);
const structureBaseUrl = "{{ url_for('get_structure') }}"; const includeNarrative = includeNarrativeCheckbox ? includeNarrativeCheckbox.checked : true;
const params = new URLSearchParams({ package_name: "{{ processed_ig.package_name }}", package_version: "{{ processed_ig.version }}", resource_type: resourceType }); // Fetch processed data for UI tabs
const fetchUrl = `${structureBaseUrl}?${params.toString()}`; const processedData = await fetchStructure(resourceType, 'snapshot', includeNarrative, false);
const response = await fetch(fetchUrl); if (!processedData || !processedData.elements) { throw new Error(`Structure data or elements missing for ${resourceType}.`); }
if (!response.ok) { const snapshotElementsCopy = JSON.parse(JSON.stringify(processedData.elements));
const errorText = await response.text(); const mustSupportPaths = new Set(processedData.must_support_paths || []);
throw new Error(`HTTP error ${response.status}: ${errorText}`); const searchParams = processedData.search_parameters || [];
} const fallbackUsed = processedData.fallback_used || false;
const data = await response.json(); const sourcePackage = processedData.source_package || 'FHIR core';
if (!data || !data.elements) { throw new Error(`Structure data or elements missing for ${resourceType}.`); }
const snapshotElementsCopy = JSON.parse(JSON.stringify(data.elements));
const mustSupportPaths = new Set(data.must_support_paths || []);
const searchParams = data.search_parameters || []; // Get search params
const fallbackUsed = data.fallback_used || false;
const fallbackSource = data.source_package || 'FHIR core';
// *** ADDED LOG: Check received search parameters ***
console.log('[populateStructure] Received search_parameters:', searchParams); console.log('[populateStructure] Received search_parameters:', searchParams);
// ************************************************* // Fetch raw StructureDefinition for raw view
const rawData = await fetchStructure(resourceType, 'snapshot', includeNarrative, true);
// Update Raw JSON View
if (rawStructureContent) { if (rawStructureContent) {
rawStructureContent.textContent = JSON.stringify(data, null, 4); rawStructureContent.textContent = JSON.stringify(rawData, null, 4);
if (rawStructureContent.classList.contains('hljs')) { rawStructureContent.classList.remove('hljs'); } if (rawStructureContent.classList.contains('hljs')) { rawStructureContent.classList.remove('hljs'); }
rawStructureContent.dataset.highlighted = '';
hljs.highlightElement(rawStructureContent); hljs.highlightElement(rawStructureContent);
if (fallbackUsed && structureFallbackMessage) { if (fallbackUsed && structureFallbackMessage) {
structureFallbackMessage.textContent = `Note: Displaying structure from fallback package: ${fallbackSource}`; structureFallbackMessage.textContent = `Note: Displaying structure from fallback package: ${sourcePackage}`;
structureFallbackMessage.style.display = 'block'; structureFallbackMessage.style.display = 'block';
} }
} else { if (rawStructureContent) rawStructureContent.textContent = '(Raw content display element not found)';} } else { if (rawStructureContent) rawStructureContent.textContent = '(Raw content display element not found)'; }
// Render the Tree Views
for (const view of views) { for (const view of views) {
console.log(`PopulateStructure V4: Rendering view '${view}' for ${resourceType}`); console.log(`PopulateStructure V4: Rendering view '${view}' for ${resourceType}`);
const treeContainer = document.getElementById(`structure-tree-${view}`); const treeContainer = document.getElementById(`structure-tree-${view}`);
if (!treeContainer) { console.error(`Container missing for ${view}`); continue; } if (!treeContainer) { console.error(`Container missing for ${view}`); continue; }
await renderStructureTree(resourceType, view, JSON.parse(JSON.stringify(snapshotElementsCopy)), mustSupportPaths); await renderStructureTree(resourceType, view, JSON.parse(JSON.stringify(snapshotElementsCopy)), mustSupportPaths);
} }
renderConstraintsTable(processedData.elements);
// Render Constraints & Bindings tables renderBindingsTable(processedData.elements);
renderConstraintsTable(data.elements); renderSearchParametersTable(searchParams);
renderBindingsTable(data.elements);
// Render Search Params table (passing potentially empty array)
renderSearchParametersTable(searchParams); // Pass the retrieved data
} catch (error) { } catch (error) {
console.error("Error populating structure:", error); console.error("Error populating structure:", error);
// ... existing error handling ...
views.forEach(v => { const tc = document.getElementById(`structure-tree-${v}`); if(tc) tc.innerHTML = `<div class="alert alert-danger m-2">Error loading structure: ${error.message}</div>`; }); views.forEach(v => { const tc = document.getElementById(`structure-tree-${v}`); if(tc) tc.innerHTML = `<div class="alert alert-danger m-2">Error loading structure: ${error.message}</div>`; });
if (constraintsTableBody) constraintsTableBody.innerHTML = `<tr><td colspan="5" class="text-danger text-center">Error loading constraints: ${error.message}</td></tr>`; if (constraintsTableBody) constraintsTableBody.innerHTML = `<tr><td colspan="5" class="text-danger text-center">Error loading constraints: ${error.message}</td></tr>`;
if (bindingsTableBody) bindingsTableBody.innerHTML = `<tr><td colspan="4" class="text-danger text-center">Error loading bindings: ${error.message}</td></tr>`; if (bindingsTableBody) bindingsTableBody.innerHTML = `<tr><td colspan="4" class="text-danger text-center">Error loading bindings: ${error.message}</td></tr>`;
if (searchParamsTableBody) searchParamsTableBody.innerHTML = `<tr><td colspan="4" class="text-danger text-center">Error loading search params: ${error.message}</td></tr>`; // Show error here too if (searchParamsTableBody) searchParamsTableBody.innerHTML = `<tr><td colspan="4" class="text-danger text-center">Error loading search params: ${error.message}</td></tr>`;
if (rawStructureContent) { rawStructureContent.textContent = `Error: ${error.message}`; } if (rawStructureContent) { rawStructureContent.textContent = `Error: ${error.message}`; }
} finally { } finally {
if (structureLoading) structureLoading.style.display = 'none'; if (structureLoading) structureLoading.style.display = 'none';
if (rawStructureLoading) rawStructureLoading.style.display = 'none'; if (rawStructureLoading) rawStructureLoading.style.display = 'none';
} }
} // End populateStructure V4 }// End populateStructure V5
// --- Populate Example Selector Dropdown --- // --- Populate Example Selector Dropdown ---
function populateExampleSelector(resourceOrProfileIdentifier) { function populateExampleSelector(resourceOrProfileIdentifier) {
@ -1040,24 +995,73 @@ async function fetchAndDisplayExample(selectedFilePath) {
const resetCopyButton = (button, tooltipInstance, originalTitle) => { if (tooltipInstance && button?.isConnected) { tooltipInstance.hide(); button.setAttribute('data-bs-original-title', originalTitle); button.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); } }; const resetCopyButton = (button, tooltipInstance, originalTitle) => { if (tooltipInstance && button?.isConnected) { tooltipInstance.hide(); button.setAttribute('data-bs-original-title', originalTitle); button.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); } };
resetCopyButton(copyRawExButton, copyRawExTooltipInstance, 'Copy Raw Content'); resetCopyButton(copyPrettyJsonButton, copyPrettyJsonTooltipInstance, 'Copy JSON'); resetCopyButton(copyRawExButton, copyRawExTooltipInstance, 'Copy Raw Content'); resetCopyButton(copyPrettyJsonButton, copyPrettyJsonTooltipInstance, 'Copy JSON');
if (!selectedFilePath) { exampleLoading.style.display = 'none'; return; } if (!selectedFilePath) { exampleLoading.style.display = 'none'; return; }
const exampleBaseUrl = "{{ url_for('get_example') }}"; const exampleParams = new URLSearchParams({ package_name: "{{ processed_ig.package_name }}", package_version: "{{ processed_ig.version }}", filename: selectedFilePath }); const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`; const exampleBaseUrl = "{{ url_for('get_example') }}";
const includeNarrative = includeNarrativeCheckbox ? includeNarrativeCheckbox.checked : true;
const exampleParams = new URLSearchParams({
package_name: "{{ processed_ig.package_name }}",
version: "{{ processed_ig.version }}", // Changed from package_version to version
filename: selectedFilePath,
include_narrative: includeNarrative
});
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
try { try {
const response = await fetch(exampleFetchUrl); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const rawData = await response.text(); const response = await fetch(exampleFetchUrl);
if(exampleFilename) exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`; if(exampleContentRaw) exampleContentRaw.textContent = rawData; let prettyJson = '(Not valid JSON)'; if (!response.ok) { throw new Error(`HTTP error ${response.status}`); }
try { prettyJson = JSON.stringify(JSON.parse(rawData), null, 4); } catch (e) { prettyJson = rawData.startsWith('<') ? '(Content is XML, not JSON)' : `(Error parsing JSON: ${e.message})`; } if(exampleContentJson) exampleContentJson.textContent = prettyJson; const rawData = await response.text();
if (exampleContentRaw) hljs.highlightElement(exampleContentRaw); if (exampleContentJson) hljs.highlightElement(exampleContentJson); if(exampleContentWrapper) exampleContentWrapper.style.display = 'block'; if(exampleFilename) exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
} catch (error) { console.error("Error fetching or displaying example:", error); if (exampleFilename) exampleFilename.textContent = 'Error loading example'; if (exampleContentRaw) exampleContentRaw.textContent = `Error: ${error.message}`; if (exampleContentJson) exampleContentJson.textContent = `Error: ${error.message}`; if (exampleContentWrapper) exampleContentWrapper.style.display = 'block'; } if(exampleContentRaw) exampleContentRaw.textContent = rawData;
finally { if (exampleLoading) exampleLoading.style.display = 'none'; } let prettyJson = '(Not valid JSON)';
try { prettyJson = JSON.stringify(JSON.parse(rawData), null, 4); } catch (e) { prettyJson = rawData.startsWith('<') ? '(Content is XML, not JSON)' : `(Error parsing JSON: ${e.message})`; }
if(exampleContentJson) exampleContentJson.textContent = prettyJson;
if (exampleContentRaw) hljs.highlightElement(exampleContentRaw);
if (exampleContentJson) hljs.highlightElement(exampleContentJson);
if(exampleContentWrapper) exampleContentWrapper.style.display = 'block';
} catch (error) {
console.error("Error fetching or displaying example:", error);
if (exampleFilename) exampleFilename.textContent = 'Error loading example';
if (exampleContentRaw) exampleContentRaw.textContent = `Error: ${error.message}`;
if (exampleContentJson) exampleContentJson.textContent = `Error: ${error.message}`;
if (exampleContentWrapper) exampleContentWrapper.style.display = 'block';
} finally {
if (exampleLoading) exampleLoading.style.display = 'none';
}
} }
// --- Generic Copy Logic Function --- // --- Generic Copy Logic Function ---
function setupCopyButton(buttonElement, sourceElement, tooltipInstance, successTitle, originalTitle, errorTitle, nothingTitle) { function setupCopyButton(buttonElement, sourceElement, tooltipInstance, successTitle, originalTitle, errorTitle, nothingTitle) {
if (!buttonElement || !sourceElement) { return null; } if (!buttonElement || !sourceElement) { return null; }
bootstrap.Tooltip.getInstance(buttonElement)?.dispose(); bootstrap.Tooltip.getInstance(buttonElement)?.dispose();
tooltipInstance = new bootstrap.Tooltip(buttonElement, { title: originalTitle }); tooltipInstance = new bootstrap.Tooltip(buttonElement, { title: originalTitle });
const clickHandler = () => { const textToCopy = sourceElement.textContent; const copyIcon = buttonElement.querySelector('i'); const updateTooltip = (newTitle, iconClass) => { buttonElement.setAttribute('data-bs-original-title', newTitle); tooltipInstance?.setContent({ '.tooltip-inner': newTitle }); tooltipInstance?.show(); if (copyIcon) copyIcon.className = `bi ${iconClass}`; setTimeout(() => { if (buttonElement.isConnected) { tooltipInstance?.hide(); buttonElement.setAttribute('data-bs-original-title', originalTitle); if (copyIcon) copyIcon.className = 'bi bi-clipboard'; } }, 1500); }; if (textToCopy && textToCopy.trim() && !textToCopy.startsWith('(Could not') && !textToCopy.startsWith('(Not valid') && !textToCopy.startsWith('Error:')) { navigator.clipboard.writeText(textToCopy).then(() => { updateTooltip(successTitle, 'bi-check-lg'); }).catch(err => { console.error(`Copy failed:`, err); updateTooltip(errorTitle, 'bi-exclamation-triangle'); }); } else { updateTooltip(nothingTitle, 'bi-clipboard'); } }; const clickHandler = () => {
buttonElement.removeEventListener('click', clickHandler); buttonElement.addEventListener('click', clickHandler); return tooltipInstance; const textToCopy = sourceElement.textContent;
const copyIcon = buttonElement.querySelector('i');
const updateTooltip = (newTitle, iconClass) => {
buttonElement.setAttribute('data-bs-original-title', newTitle);
tooltipInstance?.setContent({ '.tooltip-inner': newTitle });
tooltipInstance?.show();
if (copyIcon) copyIcon.className = `bi ${iconClass}`;
setTimeout(() => {
if (buttonElement.isConnected) {
tooltipInstance?.hide();
buttonElement.setAttribute('data-bs-original-title', originalTitle);
if (copyIcon) copyIcon.className = 'bi bi-clipboard';
}
}, 1500);
};
if (textToCopy && textToCopy.trim() && !textToCopy.startsWith('(Could not') && !textToCopy.startsWith('(Not valid') && !textToCopy.startsWith('Error:')) {
navigator.clipboard.writeText(textToCopy).then(() => {
updateTooltip(successTitle, 'bi-check-lg');
}).catch(err => {
console.error(`Copy failed:`, err);
updateTooltip(errorTitle, 'bi-exclamation-triangle');
});
} else {
updateTooltip(nothingTitle, 'bi-clipboard');
}
};
buttonElement.removeEventListener('click', clickHandler);
buttonElement.addEventListener('click', clickHandler);
return tooltipInstance;
} }
// --- Function to Render Constraints Table --- // --- Function to Render Constraints Table ---
@ -1075,9 +1079,7 @@ function renderConstraintsTable(elements) {
elements = []; // Prevent errors later elements = []; // Prevent errors later
} }
elements.forEach(element => { elements.forEach(element => {
// Check if element exists and has a constraint property which is an array
if (element && element.constraint && Array.isArray(element.constraint) && element.constraint.length > 0) { if (element && element.constraint && Array.isArray(element.constraint) && element.constraint.length > 0) {
element.constraint.forEach(constraint => { element.constraint.forEach(constraint => {
if (constraint) { // Ensure constraint object itself exists if (constraint) { // Ensure constraint object itself exists
@ -1106,10 +1108,8 @@ function renderConstraintsTable(elements) {
} }
// Re-run highlight.js for the new code blocks // Re-run highlight.js for the new code blocks
// Use setTimeout to allow the DOM to update before highlighting
setTimeout(() => { setTimeout(() => {
document.querySelectorAll('#constraints-table code.language-fhirpath').forEach((block) => { document.querySelectorAll('#constraints-table code.language-fhirpath').forEach((block) => {
// Remove existing highlight classes if any, before re-highlighting
block.classList.remove('hljs'); block.classList.remove('hljs');
hljs.highlightElement(block); hljs.highlightElement(block);
}); });
@ -1132,7 +1132,6 @@ function renderBindingsTable(elements) {
} }
elements.forEach(element => { elements.forEach(element => {
// Check if element exists and has a binding property which is an object
if (element && element.binding && typeof element.binding === 'object') { if (element && element.binding && typeof element.binding === 'object') {
bindingsFound = true; bindingsFound = true;
const binding = element.binding; const binding = element.binding;
@ -1143,7 +1142,6 @@ function renderBindingsTable(elements) {
if (binding.valueSet) { if (binding.valueSet) {
const valueSetLink = document.createElement('a'); const valueSetLink = document.createElement('a');
valueSetLink.href = binding.valueSet; // Ideally, resolve canonical to actual URL if possible valueSetLink.href = binding.valueSet; // Ideally, resolve canonical to actual URL if possible
// Try to show last part of URL as name, or full URL if no '/'
const urlParts = binding.valueSet.split('/'); const urlParts = binding.valueSet.split('/');
valueSetLink.textContent = urlParts.length > 1 ? urlParts[urlParts.length - 1] : binding.valueSet; valueSetLink.textContent = urlParts.length > 1 ? urlParts[urlParts.length - 1] : binding.valueSet;
valueSetLink.target = '_blank'; // Open in new tab valueSetLink.target = '_blank'; // Open in new tab
@ -1170,110 +1168,71 @@ function renderBindingsTable(elements) {
// --- Function to Render Search Parameters Table (Includes Conformance Badges & Tooltips) --- // --- Function to Render Search Parameters Table (Includes Conformance Badges & Tooltips) ---
function renderSearchParametersTable(searchParamsData) { function renderSearchParametersTable(searchParamsData) {
// Find the table body element using its ID
const tableBody = document.getElementById('searchparams-table')?.querySelector('tbody'); const tableBody = document.getElementById('searchparams-table')?.querySelector('tbody');
// Check if the table body element exists
if (!tableBody) { if (!tableBody) {
console.error('Search Params table body element (#searchparams-table > tbody) not found'); console.error('Search Params table body element (#searchparams-table > tbody) not found');
return; // Exit if the table body isn't found return;
} }
tableBody.innerHTML = ''; // Clear any existing rows (like loading indicators) tableBody.innerHTML = '';
// Log the data received for debugging
console.log('[renderSearchParametersTable] Received data:', searchParamsData); console.log('[renderSearchParametersTable] Received data:', searchParamsData);
// Check if the received data is valid and is an array with items
if (!searchParamsData || !Array.isArray(searchParamsData) || searchParamsData.length === 0) { if (!searchParamsData || !Array.isArray(searchParamsData) || searchParamsData.length === 0) {
console.log('[renderSearchParametersTable] No valid search parameter data found, displaying message.'); console.log('[renderSearchParametersTable] No valid search parameter data found, displaying message.');
// Display a message indicating no parameters were found
const row = tableBody.insertRow(); const row = tableBody.insertRow();
const cell = row.insertCell(); const cell = row.insertCell();
cell.colSpan = 4; // Span across all columns cell.colSpan = 4;
cell.textContent = 'No relevant search parameters found for this resource type.'; cell.textContent = 'No relevant search parameters found for this resource type.';
cell.style.textAlign = 'center'; cell.style.textAlign = 'center';
cell.classList.add('text-muted', 'fst-italic'); // Style the message cell.classList.add('text-muted', 'fst-italic');
return; // Exit the function return;
} }
// Sort parameters alphabetically by their 'code' before rendering
searchParamsData.sort((a, b) => (a.code || '').localeCompare(b.code || '')); searchParamsData.sort((a, b) => (a.code || '').localeCompare(b.code || ''));
// Iterate through each search parameter object in the sorted array
searchParamsData.forEach((param, index) => { searchParamsData.forEach((param, index) => {
console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param); // Log each parameter console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param);
const row = tableBody.insertRow();
const row = tableBody.insertRow(); // Create a new table row
// --- Column 1: Parameter Code ---
const nameCell = row.insertCell(); const nameCell = row.insertCell();
// Use escapeHtmlAttr (ensure this helper function exists in your scope)
// Display the parameter code within a <code> tag
nameCell.innerHTML = `<code>${escapeHtmlAttr(param.code || 'N/A')}</code>`; nameCell.innerHTML = `<code>${escapeHtmlAttr(param.code || 'N/A')}</code>`;
row.insertCell().textContent = param.type || 'N/A';
// --- Column 2: Parameter Type ---
row.insertCell().textContent = param.type || 'N/A'; // Display the parameter type
// --- Column 3: Conformance Level (with Badges and Tooltip) ---
const conformanceCell = row.insertCell(); const conformanceCell = row.insertCell();
// Default to 'Optional' if conformance isn't specified in the data
const conformanceValue = param.conformance || 'Optional'; const conformanceValue = param.conformance || 'Optional';
let badgeClass = 'bg-secondary'; // Default badge style let badgeClass = 'bg-secondary';
let titleText = 'Conformance level not specified in CapabilityStatement.'; // Default tooltip text let titleText = 'Conformance level not specified in CapabilityStatement.';
// Determine badge style and tooltip text based on the conformance value
if (conformanceValue === 'SHALL') { if (conformanceValue === 'SHALL') {
badgeClass = 'bg-danger'; // Red for SHALL (Mandatory) badgeClass = 'bg-danger';
titleText = 'Server SHALL support this parameter.'; titleText = 'Server SHALL support this parameter.';
} else if (conformanceValue === 'SHOULD') { } else if (conformanceValue === 'SHOULD') {
badgeClass = 'bg-warning text-dark'; // Yellow for SHOULD (Recommended) badgeClass = 'bg-warning text-dark';
titleText = 'Server SHOULD support this parameter.'; titleText = 'Server SHOULD support this parameter.';
} else if (conformanceValue === 'MAY') { } else if (conformanceValue === 'MAY') {
badgeClass = 'bg-info text-dark'; // Blue for MAY (Optional but defined) badgeClass = 'bg-info text-dark';
titleText = 'Server MAY support this parameter.'; titleText = 'Server MAY support this parameter.';
} else if (conformanceValue === 'Optional') { } else if (conformanceValue === 'Optional') {
badgeClass = 'bg-light text-dark border'; // Light grey for Optional (Not explicitly mentioned in Caps) badgeClass = 'bg-light text-dark border';
titleText = 'Server support for this parameter is optional.'; titleText = 'Server support for this parameter is optional.';
} }
// Add handling for other potential values like 'SHALL-NOT', 'SHOULD-NOT' if needed
// Set the tooltip attributes
conformanceCell.setAttribute('title', titleText); conformanceCell.setAttribute('title', titleText);
conformanceCell.setAttribute('data-bs-toggle', 'tooltip'); conformanceCell.setAttribute('data-bs-toggle', 'tooltip');
conformanceCell.setAttribute('data-bs-placement', 'top'); conformanceCell.setAttribute('data-bs-placement', 'top');
// Set the cell content with the styled badge
conformanceCell.innerHTML = `<span class="badge ${badgeClass}">${escapeHtmlAttr(conformanceValue)}</span>`; conformanceCell.innerHTML = `<span class="badge ${badgeClass}">${escapeHtmlAttr(conformanceValue)}</span>`;
// Initialize or re-initialize the Bootstrap tooltip for this specific cell
// This is important because the table rows are added dynamically
const existingTooltip = bootstrap.Tooltip.getInstance(conformanceCell); const existingTooltip = bootstrap.Tooltip.getInstance(conformanceCell);
if (existingTooltip) { if (existingTooltip) { existingTooltip.dispose(); }
existingTooltip.dispose(); // Remove any previous tooltip instance to avoid conflicts new bootstrap.Tooltip(conformanceCell);
}
new bootstrap.Tooltip(conformanceCell); // Create and initialize the new tooltip
// --- Column 4: Description (with FHIRPath Tooltip if available) ---
const descriptionCell = row.insertCell(); const descriptionCell = row.insertCell();
// Use escapeHtmlAttr for the description text
descriptionCell.innerHTML = `<small>${escapeHtmlAttr(param.description || 'No description available.')}</small>`; descriptionCell.innerHTML = `<small>${escapeHtmlAttr(param.description || 'No description available.')}</small>`;
// If a FHIRPath expression exists, add it as a tooltip
if (param.expression) { if (param.expression) {
descriptionCell.setAttribute('title', `FHIRPath: ${param.expression}`); descriptionCell.setAttribute('title', `FHIRPath: ${param.expression}`);
descriptionCell.setAttribute('data-bs-toggle', 'tooltip'); descriptionCell.setAttribute('data-bs-toggle', 'tooltip');
descriptionCell.setAttribute('data-bs-placement', 'top'); descriptionCell.setAttribute('data-bs-placement', 'top');
// Initialize or re-initialize the tooltip
const existingDescTooltip = bootstrap.Tooltip.getInstance(descriptionCell); const existingDescTooltip = bootstrap.Tooltip.getInstance(descriptionCell);
if (existingDescTooltip) { if (existingDescTooltip) { existingDescTooltip.dispose(); }
existingDescTooltip.dispose();
}
new bootstrap.Tooltip(descriptionCell); new bootstrap.Tooltip(descriptionCell);
} }
}); // End forEach loop over searchParamsData });
console.log('[renderSearchParametersTable] Finished rendering parameters.'); console.log('[renderSearchParametersTable] Finished rendering parameters.');
} // End renderSearchParametersTable function } // End renderSearchParametersTable
// --- DOMContentLoaded Listener --- // --- DOMContentLoaded Listener ---
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -1303,6 +1262,7 @@ document.addEventListener('DOMContentLoaded', function() {
copyRawDefButton = document.getElementById('copy-raw-def-button'); copyRawDefButton = document.getElementById('copy-raw-def-button');
copyRawExButton = document.getElementById('copy-raw-ex-button'); copyRawExButton = document.getElementById('copy-raw-ex-button');
copyPrettyJsonButton = document.getElementById('copy-pretty-json-button'); copyPrettyJsonButton = document.getElementById('copy-pretty-json-button');
includeNarrativeCheckbox = document.getElementById('includeNarrative');
// --- Initialize Highlight.js --- // --- Initialize Highlight.js ---
hljs.configure({ languages: ['json'] }); hljs.configure({ languages: ['json'] });
@ -1325,7 +1285,7 @@ document.addEventListener('DOMContentLoaded', function() {
const resourceType = link.dataset.resourceType; const resourceType = link.dataset.resourceType;
if (!resourceType) { console.error("Missing data-resource-type"); return; } if (!resourceType) { console.error("Missing data-resource-type"); return; }
console.log("Resource type link clicked:", resourceType); console.log("Resource type link clicked:", resourceType);
await populateStructure(resourceType); // Uses V9 buildTreeData, V2 populateStructure await populateStructure(resourceType);
populateExampleSelector(resourceType); populateExampleSelector(resourceType);
if (structureDisplayWrapper) { structureDisplayWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' }); } if (structureDisplayWrapper) { structureDisplayWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
}); });
@ -1336,7 +1296,21 @@ document.addEventListener('DOMContentLoaded', function() {
exampleSelect.addEventListener('change', function(event) { fetchAndDisplayExample(this.value); }); exampleSelect.addEventListener('change', function(event) { fetchAndDisplayExample(this.value); });
} else { console.warn("Example select dropdown not found."); } } else { console.warn("Example select dropdown not found."); }
// --- No Collapse Listeners Here --- // Narrative Toggle Checkbox Handler
if (includeNarrativeCheckbox) {
includeNarrativeCheckbox.addEventListener('change', async function() {
const selectedFilePath = exampleSelect?.value;
const resourceType = exampleResourceTypeTitle?.textContent;
if (selectedFilePath) {
await fetchAndDisplayExample(selectedFilePath);
}
if (resourceType && resourceType !== '(unknown)') {
await populateStructure(resourceType);
}
});
} else {
console.warn("Narrative checkbox not found.");
}
// --- Initial UI State --- // --- Initial UI State ---
if(structureDisplayWrapper) structureDisplayWrapper.style.display = 'none'; if(structureDisplayWrapper) structureDisplayWrapper.style.display = 'none';

View File

@ -8,13 +8,6 @@
<p class="lead mb-4"> <p class="lead mb-4">
Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local HAPI or a custom server to explore resources or perform searches. Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local HAPI or a custom server to explore resources or perform searches.
</p> </p>
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4">Validate FHIR Sample</a>
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary btn-lg px-4">FHIR UI Operations</a>
</div>
-------------------------------------------------------------------------------------------------------------------------------------------->
</div> </div>
</div> </div>
@ -34,6 +27,31 @@
</div> </div>
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small> <small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small>
</div> </div>
<div class="mb-3" id="authSection" style="display: none;">
<label class="form-label">Authentication</label>
<div class="row g-3 align-items-end">
<div class="col-md-5">
<select class="form-select" id="authType" name="auth_type">
<option value="none" selected>None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Authentication</option>
</select>
</div>
<div class="col-md-7" id="authInputsGroup" style="display: none;">
<div id="bearerTokenInput" style="display: none;">
<label for="bearerToken" class="form-label">Bearer Token</label>
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
</div>
<div id="basicAuthInputs" style="display: none;">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
</div>
</div>
</div>
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="fhirPath" class="form-label">FHIR Path</label> <label for="fhirPath" class="form-label">FHIR Path</label>
<input type="text" class="form-control" id="fhirPath" name="fhir_path" placeholder="e.g., Patient/wang-li" required aria-describedby="fhirPathHelp"> <input type="text" class="form-control" id="fhirPath" name="fhir_path" placeholder="e.g., Patient/wang-li" required aria-describedby="fhirPathHelp">
@ -44,13 +62,10 @@
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<input type="radio" class="btn-check" name="method" id="get" value="GET" checked> <input type="radio" class="btn-check" name="method" id="get" value="GET" checked>
<label class="btn btn-outline-success" for="get"><span class="badge bg-success">GET</span></label> <label class="btn btn-outline-success" for="get"><span class="badge bg-success">GET</span></label>
<input type="radio" class="btn-check" name="method" id="post" value="POST"> <input type="radio" class="btn-check" name="method" id="post" value="POST">
<label class="btn btn-outline-primary" for="post"><span class="badge bg-primary">POST</span></label> <label class="btn btn-outline-primary" for="post"><span class="badge bg-primary">POST</span></label>
<input type="radio" class="btn-check" name="method" id="put" value="PUT"> <input type="radio" class="btn-check" name="method" id="put" value="PUT">
<label class="btn btn-outline-warning" for="put"><span class="badge bg-warning text-dark">PUT</span></label> <label class="btn btn-outline-warning" for="put"><span class="badge bg-warning text-dark">PUT</span></label>
<input type="radio" class="btn-check" name="method" id="delete" value="DELETE"> <input type="radio" class="btn-check" name="method" id="delete" value="DELETE">
<label class="btn btn-outline-danger" for="delete"><span class="badge bg-danger">DELETE</span></label> <label class="btn btn-outline-danger" for="delete"><span class="badge bg-danger">DELETE</span></label>
</div> </div>
@ -108,94 +123,90 @@ document.addEventListener('DOMContentLoaded', function() {
const copyRequestBodyButton = document.getElementById('copyRequestBody'); const copyRequestBodyButton = document.getElementById('copyRequestBody');
const copyResponseHeadersButton = document.getElementById('copyResponseHeaders'); const copyResponseHeadersButton = document.getElementById('copyResponseHeaders');
const copyResponseBodyButton = document.getElementById('copyResponseBody'); const copyResponseBodyButton = document.getElementById('copyResponseBody');
const authSection = document.getElementById('authSection');
const authTypeSelect = document.getElementById('authType');
const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
// Basic check for critical elements // Basic check for critical elements
if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) { if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) {
console.error("One or more critical UI elements could not be found. Script execution halted."); console.error("One or more critical UI elements could not be found. Script execution halted.");
alert("Error initializing UI components. Please check the console."); alert("Error initializing UI components. Please check the console.");
return; // Stop script execution return;
} }
console.log("All critical elements checked/found."); console.log("All critical elements checked/found.");
// --- State Variable --- // --- State Variable ---
// Default assumes standalone, will be forced otherwise by appMode check below
let useLocalHapi = true; let useLocalHapi = true;
// --- Get App Mode from Flask Context --- // --- Get App Mode from Flask Context ---
// Ensure this variable is correctly passed from Flask using the context_processor
const appMode = '{{ app_mode | default("standalone") | lower }}'; const appMode = '{{ app_mode | default("standalone") | lower }}';
console.log('App Mode Detected:', appMode); console.log('App Mode Detected:', appMode);
// --- DEFINE HELPER FUNCTIONS --- // --- Helper Functions ---
// Validates request body, returns null on error, otherwise returns body string or empty string
function validateRequestBody(method, path) { function validateRequestBody(method, path) {
if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined; if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined;
const bodyValue = requestBodyInput.value.trim(); const bodyValue = requestBodyInput.value.trim();
requestBodyInput.classList.remove('is-invalid'); // Reset validation requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none'; jsonError.style.display = 'none';
if (!bodyValue) return ''; // Empty body is valid for POST/PUT if (!bodyValue) return '';
const isSearch = path && path.endsWith('_search'); const isSearch = path && path.endsWith('_search');
const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('['); const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('[');
const isXml = bodyValue.startsWith('<'); const isXml = bodyValue.startsWith('<');
const isForm = !isJson && !isXml; const isForm = !isJson && !isXml;
if (method === 'POST' && isSearch && isForm) { // POST Search with form params if (method === 'POST' && isSearch && isForm) {
return bodyValue; return bodyValue;
} else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON/XML } else if (method === 'POST' || method === 'PUT') {
if (isJson) { if (isJson) {
try { JSON.parse(bodyValue); return bodyValue; } try { JSON.parse(bodyValue); return bodyValue; }
catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; } catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; }
} else if (isXml) { } else if (isXml) {
// Basic XML check is difficult in JS, accept it for now
// Backend or target server will validate fully
return bodyValue; return bodyValue;
} else { // Neither JSON nor XML, and not a POST search form } else {
jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).'; jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).';
} }
requestBodyInput.classList.add('is-invalid'); requestBodyInput.classList.add('is-invalid');
jsonError.style.display = 'block'; jsonError.style.display = 'block';
return null; // Indicate validation error return null;
} }
return undefined; // Indicate no body should be sent for GET/DELETE return undefined;
} }
// Cleans path for proxying
function cleanFhirPath(path) { function cleanFhirPath(path) {
if (!path) return ''; if (!path) return '';
// Remove optional leading 'r4/' or 'fhir/', then trim slashes
return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, ''); return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
} }
// Copies text to clipboard
async function copyToClipboard(text, button) { async function copyToClipboard(text, button) {
if (text === null || text === undefined || !button || !navigator.clipboard) return; if (text === null || text === undefined || !button || !navigator.clipboard) return;
try { try {
await navigator.clipboard.writeText(String(text)); await navigator.clipboard.writeText(String(text));
const originalIcon = button.innerHTML; const originalIcon = button.innerHTML;
button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!'; button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 1500); setTimeout(() => { if (button.isConnected) button.innerHTML = originalIcon; }, 1500);
} catch (err) { console.error('Copy failed:', err); /* Optionally alert user */ } } catch (err) { console.error('Copy failed:', err); }
} }
// Updates the UI elements related to the server toggle button/input
function updateServerToggleUI() { function updateServerToggleUI() {
if (appMode === 'lite') { if (appMode === 'lite') {
// LITE MODE: Force Custom URL, disable toggle useLocalHapi = false;
useLocalHapi = false; // Force state
toggleServerButton.disabled = true; toggleServerButton.disabled = true;
toggleServerButton.classList.add('disabled'); toggleServerButton.classList.add('disabled');
toggleServerButton.style.pointerEvents = 'none'; // Make unclickable toggleServerButton.style.pointerEvents = 'none';
toggleServerButton.setAttribute('aria-disabled', 'true'); toggleServerButton.setAttribute('aria-disabled', 'true');
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode"; toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
toggleLabel.textContent = 'Use Custom URL'; // Always show this label toggleLabel.textContent = 'Use Custom URL';
fhirServerUrlInput.style.display = 'block'; // Always show input fhirServerUrlInput.style.display = 'block';
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)"; fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
fhirServerUrlInput.required = true; // Make required in lite mode fhirServerUrlInput.required = true;
authSection.style.display = 'block';
} else { } else {
// STANDALONE MODE: Allow toggle
toggleServerButton.disabled = false; toggleServerButton.disabled = false;
toggleServerButton.classList.remove('disabled'); toggleServerButton.classList.remove('disabled');
toggleServerButton.style.pointerEvents = 'auto'; toggleServerButton.style.pointerEvents = 'auto';
@ -204,113 +215,179 @@ document.addEventListener('DOMContentLoaded', function() {
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL'; toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block'; fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4"; fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
fhirServerUrlInput.required = !useLocalHapi; // Required only if custom is selected fhirServerUrlInput.required = !useLocalHapi;
authSection.style.display = useLocalHapi ? 'none' : 'block';
} }
fhirServerUrlInput.classList.remove('is-invalid'); // Clear validation state on toggle fhirServerUrlInput.classList.remove('is-invalid');
updateAuthInputsUI();
console.log(`UI Updated: useLocalHapi=${useLocalHapi}, Button Disabled=${toggleServerButton.disabled}, Input Visible=${fhirServerUrlInput.style.display !== 'none'}, Input Required=${fhirServerUrlInput.required}`); console.log(`UI Updated: useLocalHapi=${useLocalHapi}, Button Disabled=${toggleServerButton.disabled}, Input Visible=${fhirServerUrlInput.style.display !== 'none'}, Input Required=${fhirServerUrlInput.required}`);
} }
// Toggles the server selection state (only effective in Standalone mode) function updateAuthInputsUI() {
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
const authType = authTypeSelect.value;
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
}
function toggleServer() { function toggleServer() {
if (appMode === 'lite') { if (appMode === 'lite') {
console.log("Toggle ignored: Lite mode active."); console.log("Toggle ignored: Lite mode active.");
return; // Do nothing in lite mode return;
} }
useLocalHapi = !useLocalHapi; useLocalHapi = !useLocalHapi;
if (useLocalHapi && fhirServerUrlInput) { if (useLocalHapi && fhirServerUrlInput) {
fhirServerUrlInput.value = ''; // Clear custom URL when switching to local fhirServerUrlInput.value = '';
} }
updateServerToggleUI(); // Update UI based on new state updateServerToggleUI();
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`); console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
} }
// Updates visibility of the Request Body textarea based on selected HTTP method
function updateRequestBodyVisibility() { function updateRequestBodyVisibility() {
const selectedMethod = document.querySelector('input[name="method"]:checked')?.value; const selectedMethod = document.querySelector('input[name="method"]:checked')?.value;
if (!requestBodyGroup || !selectedMethod) return; if (!requestBodyGroup || !selectedMethod) return;
const showBody = (selectedMethod === 'POST' || selectedMethod === 'PUT'); const showBody = (selectedMethod === 'POST' || selectedMethod === 'PUT');
requestBodyGroup.style.display = showBody ? 'block' : 'none'; requestBodyGroup.style.display = showBody ? 'block' : 'none';
if (!showBody) { // Clear body and errors if body not needed if (!showBody) {
if (requestBodyInput) requestBodyInput.value = ''; if (requestBodyInput) requestBodyInput.value = '';
if (jsonError) jsonError.style.display = 'none'; if (jsonError) jsonError.style.display = 'none';
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid'); if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
} }
} }
// --- INITIAL SETUP & MODE CHECK --- // --- Initial Setup ---
updateServerToggleUI(); // Set initial UI based on detected mode and default state updateServerToggleUI();
updateRequestBodyVisibility(); // Set initial visibility based on default method (GET) updateRequestBodyVisibility();
// --- ATTACH EVENT LISTENERS --- // --- Event Listeners ---
toggleServerButton.addEventListener('click', toggleServer); toggleServerButton.addEventListener('click', toggleServer);
if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); } if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); }
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody( document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value )); } if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody(document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value)); }
if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); } if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); }
if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); } if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); }
if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); } if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); }
if (authTypeSelect) { authTypeSelect.addEventListener('change', updateAuthInputsUI); }
// --- Send Request Button Listener --- // --- Send Request Button Listener ---
sendButton.addEventListener('click', async function() { sendButton.addEventListener('click', async function() {
console.log("Send Request button clicked."); console.log("Send Request button clicked.");
// --- UI Reset --- sendButton.disabled = true;
sendButton.disabled = true; sendButton.textContent = 'Sending...'; sendButton.textContent = 'Sending...';
responseCard.style.display = 'none'; responseCard.style.display = 'none';
responseStatus.textContent = ''; responseHeaders.textContent = ''; responseBody.textContent = ''; responseStatus.textContent = '';
responseStatus.className = 'badge'; // Reset badge class responseHeaders.textContent = '';
fhirServerUrlInput.classList.remove('is-invalid'); // Reset validation responseBody.textContent = '';
if(requestBodyInput) requestBodyInput.classList.remove('is-invalid'); responseStatus.className = 'badge';
if(jsonError) jsonError.style.display = 'none'; fhirServerUrlInput.classList.remove('is-invalid');
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
if (jsonError) jsonError.style.display = 'none';
if (bearerTokenInput) bearerTokenInput.classList.remove('is-invalid');
if (usernameInput) usernameInput.classList.remove('is-invalid');
if (passwordInput) passwordInput.classList.remove('is-invalid');
// --- Get Values --- // --- Get Values ---
const path = fhirPathInput.value.trim(); const path = fhirPathInput.value.trim();
const method = document.querySelector('input[name="method"]:checked')?.value; const method = document.querySelector('input[name="method"]:checked')?.value;
const customUrl = fhirServerUrlInput.value.trim(); const customUrl = fhirServerUrlInput.value.trim();
let body = undefined; let body = undefined;
const authType = authTypeSelect ? authTypeSelect.value : 'none';
const bearerToken = bearerTokenInput ? bearerTokenInput.value.trim() : '';
const username = usernameInput ? usernameInput.value.trim() : '';
const password = passwordInput ? passwordInput.value : '';
// --- Basic Input Validation --- // --- Basic Input Validation ---
if (!path) { alert('Please enter a FHIR Path.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; } if (!path) {
if (!method) { alert('Please select a Request Type.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; } alert('Please enter a FHIR Path.');
if (!useLocalHapi && !customUrl) { // Custom URL mode needs a URL sendButton.disabled = false;
alert('Please enter a custom FHIR Server URL.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; sendButton.textContent = 'Send Request';
return;
} }
if (!useLocalHapi && customUrl) { // Validate custom URL format if (!method) {
alert('Please select a Request Type.');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (!useLocalHapi && !customUrl) {
alert('Please enter a custom FHIR Server URL.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (!useLocalHapi && customUrl) {
try { new URL(customUrl); } try { new URL(customUrl); }
catch (_) { alert('Invalid custom FHIR Server URL format.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; } catch (_) {
alert('Invalid custom FHIR Server URL format.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
} }
// --- Validate & Get Body (if needed) --- // --- Validate Authentication ---
if (!useLocalHapi) {
if (authType === 'bearer' && !bearerToken) {
alert('Please enter a Bearer Token.');
bearerTokenInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (authType === 'basic' && (!username || !password)) {
alert('Please enter both Username and Password for Basic Authentication.');
if (!username) usernameInput.classList.add('is-invalid');
if (!password) passwordInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
}
// --- Validate & Get Body ---
if (method === 'POST' || method === 'PUT') { if (method === 'POST' || method === 'PUT') {
body = validateRequestBody(method, path); body = validateRequestBody(method, path);
if (body === null) { // null indicates validation error if (body === null) {
alert('Request body contains invalid JSON/Format.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; alert('Request body contains invalid JSON/Format.');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
} }
// If body is empty string, ensure it's treated as such for fetch
if (body === '') body = ''; if (body === '') body = '';
} }
// --- Determine Fetch URL and Headers --- // --- Determine Fetch URL and Headers ---
const cleanedPath = cleanFhirPath(path); const cleanedPath = cleanFhirPath(path);
const finalFetchUrl = '/fhir/' + cleanedPath; // Always send to the backend proxy endpoint const finalFetchUrl = '/fhir/' + cleanedPath;
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' }; const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
// Determine Content-Type if body exists
if (body !== undefined) { if (body !== undefined) {
if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; } if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; } else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; } else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
else if (body) { headers['Content-Type'] = 'application/fhir+json'; } // Default if unknown but present else if (body) { headers['Content-Type'] = 'application/fhir+json'; }
} }
// Add Custom Target Header if needed
if (!useLocalHapi && customUrl) { if (!useLocalHapi && customUrl) {
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, ''); // Send custom URL without trailing slash headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, '');
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']); console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
console.log("Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
const credentials = btoa(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
console.log("Adding header Authorization: Basic <redacted>");
}
} }
// Add CSRF token ONLY if sending to local proxy (for modifying methods)
const csrfTokenInput = form.querySelector('input[name="csrf_token"]'); const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null; const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
// Include DELETE method for CSRF check
if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) { if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) {
headers['X-CSRFToken'] = csrfToken; headers['X-CSRFToken'] = csrfToken;
console.log("CSRF Token added for local request."); console.log("CSRF Token added for local request.");
@ -319,7 +396,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
console.log(`Executing Fetch: Method=${method}, URL=${finalFetchUrl}, LocalHAPI=${useLocalHapi}`); console.log(`Executing Fetch: Method=${method}, URL=${finalFetchUrl}, LocalHAPI=${useLocalHapi}`);
console.log("Request Headers:", headers); console.log("Request Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
if (body !== undefined) console.log("Request Body (first 300 chars):", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : "")); if (body !== undefined) console.log("Request Body (first 300 chars):", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
// --- Make the Fetch Request --- // --- Make the Fetch Request ---
@ -327,10 +404,9 @@ document.addEventListener('DOMContentLoaded', function() {
const response = await fetch(finalFetchUrl, { const response = await fetch(finalFetchUrl, {
method: method, method: method,
headers: headers, headers: headers,
body: body // Will be undefined for GET/DELETE body: body
}); });
// --- Process Response ---
responseCard.style.display = 'block'; responseCard.style.display = 'block';
responseStatus.textContent = `${response.status} ${response.statusText}`; responseStatus.textContent = `${response.status} ${response.statusText}`;
responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`; responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`;
@ -343,13 +419,10 @@ document.addEventListener('DOMContentLoaded', function() {
let responseBodyText = await response.text(); let responseBodyText = await response.text();
let displayBody = responseBodyText; let displayBody = responseBodyText;
// Attempt to pretty-print JSON
if (responseContentType.includes('json') && responseBodyText.trim()) { if (responseContentType.includes('json') && responseBodyText.trim()) {
try { displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2); } try { displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2); }
catch (e) { console.warn("Failed to pretty-print JSON response:", e); /* Show raw text */ } catch (e) { console.warn("Failed to pretty-print JSON response:", e); }
} } else if (responseContentType.includes('xml') && responseBodyText.trim()) {
// Attempt to pretty-print XML (basic indentation)
else if (responseContentType.includes('xml') && responseBodyText.trim()) {
try { try {
let formattedXml = ''; let formattedXml = '';
let indent = 0; let indent = 0;
@ -357,24 +430,23 @@ document.addEventListener('DOMContentLoaded', function() {
for (let i = 0; i < xmlLines.length; i++) { for (let i = 0; i < xmlLines.length; i++) {
const node = xmlLines[i]; const node = xmlLines[i];
if (!node || node.trim().length === 0) continue; if (!node || node.trim().length === 0) continue;
if (node.match(/^<\/\w/)) indent--; // Closing tag if (node.match(/^<\/\w/)) indent--;
formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n'; formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n';
if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++; // Opening tag if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++;
} }
displayBody = formattedXml.trim(); displayBody = formattedXml.trim();
} catch(e) { console.warn("Basic XML formatting failed:", e); /* Show raw text */ } } catch (e) { console.warn("Basic XML formatting failed:", e); }
} }
responseBody.textContent = displayBody; responseBody.textContent = displayBody;
// Highlight response body if Prism.js is available
if (typeof Prism !== 'undefined') { if (typeof Prism !== 'undefined') {
responseBody.className = 'border p-2 bg-light'; // Reset base classes responseBody.className = 'border p-2 bg-light';
if (responseContentType.includes('json')) { if (responseContentType.includes('json')) {
responseBody.classList.add('language-json'); responseBody.classList.add('language-json');
} else if (responseContentType.includes('xml')) { } else if (responseContentType.includes('xml')) {
responseBody.classList.add('language-xml'); responseBody.classList.add('language-xml');
} // Add other languages if needed }
Prism.highlightElement(responseBody); Prism.highlightElement(responseBody);
} }
@ -384,7 +456,6 @@ document.addEventListener('DOMContentLoaded', function() {
responseStatus.textContent = `Network Error`; responseStatus.textContent = `Network Error`;
responseStatus.className = 'badge bg-danger'; responseStatus.className = 'badge bg-danger';
responseHeaders.textContent = 'N/A'; responseHeaders.textContent = 'N/A';
// Provide a more informative error message
let errorDetail = `Error: ${error.message}\n\n`; let errorDetail = `Error: ${error.message}\n\n`;
if (useLocalHapi) { if (useLocalHapi) {
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`; errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`;
@ -393,10 +464,10 @@ document.addEventListener('DOMContentLoaded', function() {
} }
responseBody.textContent = errorDetail; responseBody.textContent = errorDetail;
} finally { } finally {
sendButton.disabled = false; sendButton.textContent = 'Send Request'; sendButton.disabled = false;
sendButton.textContent = 'Send Request';
} }
}); // End sendButton listener });
});
}); // End DOMContentLoaded Listener
</script> </script>
{% endblock %} {% endblock %}

View File

@ -134,11 +134,37 @@
<label class="form-label fw-bold">FHIR Server</label> <label class="form-label fw-bold">FHIR Server</label>
<div class="input-group"> <div class="input-group">
<button type="button" class="btn btn-outline-primary" id="toggleServer"> <button type="button" class="btn btn-outline-primary" id="toggleServer">
<span id="toggleLabel">Use Local HAPI</span> </button> <span id="toggleLabel">Use Local HAPI</span>
</button>
<input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4" style="display: none;" aria-describedby="fhirServerHelp"> <input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4" style="display: none;" aria-describedby="fhirServerHelp">
</div> </div>
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small> <small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
</div> </div>
<div class="mb-3" id="authSection" style="display: none;">
<label class="form-label fw-bold">Authentication</label>
<div class="row g-3 align-items-end">
<div class="col-md-5">
<select class="form-select" id="authType" name="auth_type">
<option value="none" selected>None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Authentication</option>
</select>
</div>
<div class="col-md-7" id="authInputsGroup" style="display: none;">
<div id="bearerTokenInput" style="display: none;">
<label for="bearerToken" class="form-label">Bearer Token</label>
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
</div>
<div id="basicAuthInputs" style="display: none;">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
</div>
</div>
</div>
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
</div>
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button> <button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
</form> </form>
@ -338,9 +364,15 @@ document.addEventListener('DOMContentLoaded', () => {
const swaggerUiContainer = document.getElementById('swagger-ui'); const swaggerUiContainer = document.getElementById('swagger-ui');
const selectedResourceSpan = document.getElementById('selectedResource'); const selectedResourceSpan = document.getElementById('selectedResource');
const queryListContainer = document.getElementById('queryList'); const queryListContainer = document.getElementById('queryList');
// --- Add checks if desired --- const authSection = document.getElementById('authSection');
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput /* || other critical elements */) { const authTypeSelect = document.getElementById('authType');
console.error("Crucial elements missing, stopping script."); return; const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput || !authSection || !authTypeSelect) {
console.error("Crucial elements missing, stopping script.");
return;
} }
// --- State Variables --- // --- State Variables ---
@ -355,48 +387,57 @@ document.addEventListener('DOMContentLoaded', () => {
console.log(`App Mode (Operations): ${appMode}`); console.log(`App Mode (Operations): ${appMode}`);
// <<< END ADD >>> // <<< END ADD >>>
// --- Helper Function to Update Toggle Button/Input UI (MODIFY THIS FUNCTION) --- // --- Helper Functions ---
function updateServerToggleUI() { function updateAuthInputsUI() {
// Keep checks for elements console.log(`[updateAuthInputsUI] Running, authType: ${authTypeSelect.value}`);
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) { if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) {
console.error("updateServerToggleUI: Required elements missing!"); console.error("[updateAuthInputsUI] Missing auth elements");
return; return;
} }
console.log(`updateServerToggleUI: appMode=${appMode}, current isUsingLocalHapi=${isUsingLocalHapi}`); // Debug const authType = authTypeSelect.value;
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
console.log(`[updateAuthInputsUI] authInputsGroup display: ${authInputsGroup.style.display}, bearer: ${bearerTokenInput.style.display}, basic: ${basicAuthInputs.style.display}`);
}
// <<< MODIFY THIS WHOLE IF/ELSE BLOCK >>> // --- Helper Function to Update Toggle Button/Input UI (MODIFY THIS FUNCTION) ---
function updateServerToggleUI() {
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) {
console.error("[updateServerToggleUI] Required elements missing!");
return;
}
console.log(`[updateServerToggleUI] appMode=${appMode}, isUsingLocalHapi=${isUsingLocalHapi}`);
if (appMode === 'lite') { if (appMode === 'lite') {
console.log("-> Applying Lite mode UI settings."); console.log("[updateServerToggleUI] Applying Lite mode UI settings");
isUsingLocalHapi = false; // Force state isUsingLocalHapi = false;
toggleServerButton.disabled = true; // Set disabled attribute toggleServerButton.disabled = true;
toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class toggleServerButton.classList.add('disabled');
// --- ADD !important to pointerEvents ---
toggleServerButton.style.pointerEvents = 'none !important'; toggleServerButton.style.pointerEvents = 'none !important';
// --- END ADD --- toggleServerButton.setAttribute('aria-disabled', 'true');
toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility toggleServerButton.title = "Local HAPI is not available in Lite mode";
toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip toggleLabel.textContent = 'Use Custom URL';
toggleLabel.textContent = 'Use Custom URL'; // Set label text fhirServerUrlInput.style.display = 'block';
fhirServerUrlInput.style.display = 'block'; // Show custom URL input
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)"; fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
authSection.style.display = 'block';
} else { } else {
// Standalone mode console.log("[updateServerToggleUI] Applying Standalone mode UI settings");
console.log("-> Applying Standalone mode UI settings."); toggleServerButton.disabled = false;
toggleServerButton.disabled = false; // Ensure enabled toggleServerButton.classList.remove('disabled');
toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
// --- Ensure pointerEvents is auto in standalone ---
toggleServerButton.style.pointerEvents = 'auto'; toggleServerButton.style.pointerEvents = 'auto';
// --- END --- toggleServerButton.removeAttribute('aria-disabled');
toggleServerButton.removeAttribute('aria-disabled'); // Accessibility toggleServerButton.title = "";
toggleServerButton.title = ""; // Clear tooltip
// Set text/display based on current standalone state
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL'; toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block'; fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4"; fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
authSection.style.display = isUsingLocalHapi ? 'none' : 'block';
} }
fhirServerUrlInput.classList.remove('is-invalid'); // Clear validation state fhirServerUrlInput.classList.remove('is-invalid');
console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events updateAuthInputsUI();
// <<< END MODIFICATION >>> console.log(`[updateServerToggleUI] Finished. Button disabled: ${toggleServerButton.disabled}, authSection display: ${authSection.style.display}`);
} }
// <<< REFINED fetchOperationDefinition >>> // <<< REFINED fetchOperationDefinition >>>
@ -482,47 +523,47 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// <<< END REFINED fetchOperationDefinition (v3) >>> // <<< END REFINED fetchOperationDefinition (v3) >>>
function updateServerToggleUI() { // function updateServerToggleUI() {
// Keep checks for elements // // Keep checks for elements
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) { // if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) {
console.error("updateServerToggleUI: Required elements missing!"); // console.error("updateServerToggleUI: Required elements missing!");
return; // return;
} // }
console.log(`updateServerToggleUI: appMode=<span class="math-inline">\{appMode\}, current isUsingLocalHapi\=</span>{isUsingLocalHapi}`); // Debug // console.log(`updateServerToggleUI: appMode=<span class="math-inline">\{appMode\}, current isUsingLocalHapi\=</span>{isUsingLocalHapi}`); // Debug
if (appMode === 'lite') { // if (appMode === 'lite') {
console.log("-> Applying Lite mode UI settings."); // console.log("-> Applying Lite mode UI settings.");
isUsingLocalHapi = false; // Force state // isUsingLocalHapi = false; // Force state
toggleServerButton.disabled = true; // Set disabled attribute // toggleServerButton.disabled = true; // Set disabled attribute
toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class // toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class
// --- ADD !important to pointerEvents --- // // --- ADD !important to pointerEvents ---
toggleServerButton.style.pointerEvents = 'none !important'; // toggleServerButton.style.pointerEvents = 'none !important';
// --- END ADD --- // // --- END ADD ---
toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility // toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility
toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip // toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip
toggleLabel.textContent = 'Use Custom URL'; // Set label text // toggleLabel.textContent = 'Use Custom URL'; // Set label text
fhirServerUrlInput.style.display = 'block'; // Show custom URL input // fhirServerUrlInput.style.display = 'block'; // Show custom URL input
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)"; // fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
} else { // } else {
// Standalone mode // // Standalone mode
console.log("-> Applying Standalone mode UI settings."); // console.log("-> Applying Standalone mode UI settings.");
toggleServerButton.disabled = false; // Ensure enabled // toggleServerButton.disabled = false; // Ensure enabled
toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class // toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
// --- Ensure pointerEvents is auto in standalone --- // // --- Ensure pointerEvents is auto in standalone ---
toggleServerButton.style.pointerEvents = 'auto'; // toggleServerButton.style.pointerEvents = 'auto';
// --- END --- // // --- END ---
toggleServerButton.removeAttribute('aria-disabled'); // Accessibility // toggleServerButton.removeAttribute('aria-disabled'); // Accessibility
toggleServerButton.title = ""; // Clear tooltip // toggleServerButton.title = ""; // Clear tooltip
// Set text/display based on current standalone state // // Set text/display based on current standalone state
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL'; // toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block'; // fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4"; // fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
} // }
// Clear potential validation errors regardless of mode // // Clear potential validation errors regardless of mode
if(fhirServerUrlInput) fhirServerUrlInput.classList.remove('is-invalid'); // Add check for element existence // if(fhirServerUrlInput) fhirServerUrlInput.classList.remove('is-invalid'); // Add check for element existence
console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events // console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events
} // }
// --- Server Toggle Functionality (REVISED - simplified) --- // --- Server Toggle Functionality (REVISED - simplified) ---
function toggleServerSelection() { function toggleServerSelection() {
@ -1271,153 +1312,278 @@ document.addEventListener('DOMContentLoaded', () => {
if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) { if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) {
executeButton.addEventListener('click', async () => { executeButton.addEventListener('click', async () => {
console.log("[LOG 1] Execute button clicked. Starting listener..."); console.log("[LOG 1] Execute button clicked. Starting listener...");
// --- Reset UI and Disable Button --- executeButton.disabled = true;
executeButton.disabled = true; executeButton.textContent = 'Executing...'; executeButton.textContent = 'Executing...';
executeWrapper.style.display = 'block'; executeWrapper.style.display = 'block';
// ... (reset UI elements) ... if (reqUrlOutput) reqUrlOutput.textContent = 'Building request...';
if(reqUrlOutput) reqUrlOutput.textContent = 'Building request...'; if(curlOutput) curlOutput.textContent = 'Building request...'; if(respOutputCode) respOutputCode.textContent = ''; if(respNarrativeDiv) respNarrativeDiv.innerHTML = ''; respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block'; if(respStatusDiv) respStatusDiv.textContent = 'Executing request...'; respStatusDiv.style.color = '#6c757d'; if(respFormatSelect) respFormatSelect.style.display = 'none'; respFormatSelect.value = 'json'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none'; if (curlOutput) curlOutput.textContent = 'Building request...';
if (respOutputCode) respOutputCode.textContent = '';
if (respNarrativeDiv) respNarrativeDiv.innerHTML = '';
respNarrativeDiv.style.display = 'none';
if (respOutputPre) respOutputPre.style.display = 'block';
if (respStatusDiv) respStatusDiv.textContent = 'Executing request...';
respStatusDiv.style.color = '#6c757d';
if (respFormatSelect) respFormatSelect.style.display = 'none';
respFormatSelect.value = 'json';
if (copyRespButton) copyRespButton.style.display = 'none';
if (downloadRespButton) downloadRespButton.style.display = 'none';
// --- Get Query Definition and Base URL --- const queryDef = JSON.parse(block.dataset.queryData);
const queryDef = JSON.parse(block.dataset.queryData); const method = queryDef.method; const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' }; let body; let path = queryDef.path; const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir'); let url = `${baseUrl}`; let validParams = true; const missingParams = []; const bodyParamsList = []; const method = queryDef.method;
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
let body;
let path = queryDef.path;
const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir');
let url = `${baseUrl}`;
let validParams = true;
const missingParams = [];
const bodyParamsList = [];
console.log("[LOG 2] Starting parameter processing..."); if (!isUsingLocalHapi) {
const authType = authTypeSelect.value;
const bearerToken = bearerTokenInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
console.log("Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
console.log("Adding header Authorization: Basic <redacted>");
}
}
// --- Process Path Parameters ---
// <<< FIX: Convert NodeList to Array before forEach >>>
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="path"]')).forEach(row => { Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="path"]')).forEach(row => {
const paramName = row.dataset.paramName; const input = row.querySelector('input'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === 'path'); const required = paramDef?.required; const value = input?.value.trim(); if (input) input.classList.remove('is-invalid'); const paramName = row.dataset.paramName;
if (!value && required) { validParams = false; missingParams.push(`${paramName} (path)`); if (input) input.classList.add('is-invalid'); } const input = row.querySelector('input');
else if (value) { path = path.replace(`:${paramName}`, encodeURIComponent(value)); } const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === 'path');
else { path = path.replace(`/:${paramName}`, ''); } const required = paramDef?.required;
const value = input?.value.trim();
if (input) input.classList.remove('is-invalid');
if (!value && required) {
validParams = false;
missingParams.push(`${paramName} (path)`);
if (input) input.classList.add('is-invalid');
} else if (value) {
path = path.replace(`:${paramName}`, encodeURIComponent(value));
} else {
path = path.replace(`/:${paramName}`, '');
}
}); });
if (path.includes(':')) { const remainingPlaceholders = path.match(/:(\w+)/g) || []; const requiredRemaining = queryDef.parameters.filter(p => p.in === 'path' && remainingPlaceholders.includes(`:${p.name}`) && p.required); if (requiredRemaining.length > 0) { validParams = false; missingParams.push(...requiredRemaining.map(p => `${p.name} (path)`)); requiredRemaining.forEach(p => { const el = block.querySelector(`.parameters-section tr[data-param-name="${p.name}"][data-param-in="path"] input`); if (el) el.classList.add('is-invalid'); }); } } if (path.includes(':')) {
const remainingPlaceholders = path.match(/:(\w+)/g) || [];
const requiredRemaining = queryDef.parameters.filter(p => p.in === 'path' && remainingPlaceholders.includes(`:${p.name}`) && p.required);
if (requiredRemaining.length > 0) {
validParams = false;
missingParams.push(...requiredRemaining.map(p => `${p.name} (path)`));
requiredRemaining.forEach(p => {
const el = block.querySelector(`.parameters-section tr[data-param-name="${p.name}"][data-param-in="path"] input`);
if (el) el.classList.add('is-invalid');
});
}
}
url += path.startsWith('/') ? path : `/${path}`; url += path.startsWith('/') ? path : `/${path}`;
// --- Process Query and Body Parameters ---
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
// <<< FIX: Convert NodeList to Array before forEach >>>
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="query"], .parameters-section tr[data-param-in="body (Parameters)"]')).forEach(row => { Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="query"], .parameters-section tr[data-param-in="body (Parameters)"]')).forEach(row => {
const paramName = row.dataset.paramName; const paramIn = row.dataset.paramIn; const inputElement = row.querySelector('input, select'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === paramIn); const required = paramDef?.required; let value = ''; if (inputElement) inputElement.classList.remove('is-invalid'); const paramName = row.dataset.paramName;
if (inputElement?.type === 'checkbox') { value = inputElement.checked ? (inputElement.value || 'true') : ''; } else if (inputElement) { value = inputElement.value.trim(); } const paramIn = row.dataset.paramIn;
if (value) { if (paramIn === 'query') { searchParams.set(paramName, value); } else { let paramPart = { name: paramName }; const paramType = paramDef?.type || 'string'; try { switch (paramType) { case 'boolean': paramPart.valueBoolean = (value === 'true'); break; case 'integer': case 'positiveInt': case 'unsignedInt': paramPart.valueInteger = parseInt(value, 10); break; case 'decimal': paramPart.valueDecimal = parseFloat(value); break; case 'date': paramPart.valueDate = value; break; case 'dateTime': paramPart.valueDateTime = value; break; case 'instant': try { paramPart.valueInstant = new Date(value).toISOString(); } catch (dateError) { console.warn(`Instant parse failed: ${dateError}`); paramPart.valueString = value;} break; default: paramPart[`value${paramType.charAt(0).toUpperCase() + paramType.slice(1)}`] = value; } if (Object.keys(paramPart).length > 1) { bodyParamsList.push(paramPart); } } catch (typeError) { console.error(`Error processing body param ${paramName}: ${typeError}`); bodyParamsList.push({ name: paramName, valueString: value }); } } } const inputElement = row.querySelector('input, select');
else if (required) { validParams = false; missingParams.push(`${paramName} (${paramIn})`); if (inputElement) inputElement.classList.add('is-invalid'); } const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === paramIn);
const required = paramDef?.required;
let value = '';
if (inputElement) inputElement.classList.remove('is-invalid');
if (inputElement?.type === 'checkbox') {
value = inputElement.checked ? (inputElement.value || 'true') : '';
} else if (inputElement) {
value = inputElement.value.trim();
}
if (value) {
if (paramIn === 'query') {
searchParams.set(paramName, value);
} else {
let paramPart = { name: paramName };
const paramType = paramDef?.type || 'string';
try {
switch (paramType) {
case 'boolean': paramPart.valueBoolean = (value === 'true'); break;
case 'integer': case 'positiveInt': case 'unsignedInt': paramPart.valueInteger = parseInt(value, 10); break;
case 'decimal': paramPart.valueDecimal = parseFloat(value); break;
case 'date': paramPart.valueDate = value; break;
case 'dateTime': paramPart.valueDateTime = value; break;
case 'instant': try { paramPart.valueInstant = new Date(value).toISOString(); } catch (dateError) { console.warn(`Instant parse failed: ${dateError}`); paramPart.valueString = value; } break;
default: paramPart[`value${paramType.charAt(0).toUpperCase() + paramType.slice(1)}`] = value;
}
if (Object.keys(paramPart).length > 1) { bodyParamsList.push(paramPart); }
} catch (typeError) {
console.error(`Error processing body param ${paramName}: ${typeError}`);
bodyParamsList.push({ name: paramName, valueString: value });
}
}
} else if (required) {
validParams = false;
missingParams.push(`${paramName} (${paramIn})`);
if (inputElement) inputElement.classList.add('is-invalid');
}
}); });
console.log("[LOG 3] Parameter processing finished. Valid:", validParams);
// --- Validation Check ---
if (!validParams) { if (!validParams) {
const errorMsg = `Error: Missing required parameter(s): ${[...new Set(missingParams)].join(', ')}`; const errorMsg = `Error: Missing required parameter(s): ${[...new Set(missingParams)].join(', ')}`;
console.error("[LOG 3a] Validation failed:", errorMsg); console.error("[LOG 3a] Validation failed:", errorMsg);
if(respStatusDiv) { respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; } if (respStatusDiv) { respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; }
if(reqUrlOutput) reqUrlOutput.textContent = 'Error: Invalid parameters'; if (reqUrlOutput) reqUrlOutput.textContent = 'Error: Invalid parameters';
if(curlOutput) curlOutput.textContent = 'Error: Invalid parameters'; if (curlOutput) curlOutput.textContent = 'Error: Invalid parameters';
executeButton.disabled = false; executeButton.textContent = 'Execute'; // Re-enable button executeButton.disabled = false;
return; // Stop execution executeButton.textContent = 'Execute';
return;
} }
// --- Finalize URL --- const queryString = searchParams.toString();
const queryString = searchParams.toString(); if (queryString) url += (url.includes('?') ? '&' : '?') + queryString; if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
// --- Construct Request Body ---
console.log("[LOG 4] Constructing request body...");
if (queryDef.requestBody) { if (queryDef.requestBody) {
const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json'; const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json';
headers['Content-Type'] = contentType; headers['Content-Type'] = contentType;
// <<< Refined Logic >>> if (bodyParamsList.length > 0) {
if (bodyParamsList.length > 0) { // If parameters were collected for the body if (contentType.includes('json')) {
console.log("[LOG 4a] Using bodyParamsList to construct body."); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2);
if (contentType.includes('json')) { body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); } } else if (contentType.includes('xml')) {
else if (contentType.includes('xml')) { try { body = jsonToFhirXml({ resourceType: "Parameters", parameter: bodyParamsList }); } catch (xmlErr) { console.error("Params->XML failed:", xmlErr); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }); headers['Content-Type'] = 'application/fhir+json'; alert("Failed to create XML body. Sending JSON."); } } try { body = jsonToFhirXml({ resourceType: "Parameters", parameter: bodyParamsList }); }
else { console.warn(`Unsupported Content-Type ${contentType} for Parameters body. Sending JSON.`); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); headers['Content-Type'] = 'application/fhir+json'; } catch (xmlErr) {
} else if (reqBodyTextarea && reqBodyTextarea.value.trim() !== '') { // Otherwise, if textarea exists AND has non-whitespace content console.error("Params->XML failed:", xmlErr);
console.log("[LOG 4b] Using reqBodyTextarea value for body."); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList });
body = reqBodyTextarea.value; // Use the textarea content directly headers['Content-Type'] = 'application/fhir+json';
// Convert textarea content if needed (e.g., user pasted JSON but selected XML) alert("Failed to create XML body. Sending JSON.");
if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) { try { body = jsonToFhirXml(body); } catch (e) { console.warn("Textarea JSON->XML failed", e); } } }
else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) { try { body = xmlToFhirJson(body); } catch (e) { console.warn("Textarea XML->JSON failed", e); } } } else {
} else { // No body params and textarea is missing or empty console.warn(`Unsupported Content-Type ${contentType} for Parameters body. Sending JSON.`);
console.log("[LOG 4c] No body parameters and textarea is empty/missing. Setting body to empty string."); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2);
body = ''; // Ensure body is an empty string if nothing else applies headers['Content-Type'] = 'application/fhir+json';
}
} else if (reqBodyTextarea && reqBodyTextarea.value.trim() !== '') {
body = reqBodyTextarea.value;
if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) {
try { body = jsonToFhirXml(body); } catch (e) { console.warn("Textarea JSON->XML failed", e); }
} else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) {
try { body = xmlToFhirJson(body); } catch (e) { console.warn("Textarea XML->JSON failed", e); }
}
} else {
body = '';
} }
// Add CSRF token if needed
console.log("[LOG 4d] Current isUsingLocalHapi state:", isUsingLocalHapi); // Log HAPI state
if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
console.log("[LOG 4e] Attempting to add CSRF token for local HAPI modifying request.");
// Find the CSRF token input within the form
const csrfTokenInput = fhirOperationsForm.querySelector('input[name="csrf_token"]'); const csrfTokenInput = fhirOperationsForm.querySelector('input[name="csrf_token"]');
if (csrfTokenInput && csrfTokenInput.value) { if (csrfTokenInput && csrfTokenInput.value) {
// Add the token value to the request headers object
headers['X-CSRFToken'] = csrfTokenInput.value; headers['X-CSRFToken'] = csrfTokenInput.value;
console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No'); // Verify it was added console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No');
} else { } else {
// Log an error if the token input wasn't found or was empty
console.error("[LOG 4g] CSRF token input not found or has no value!"); console.error("[LOG 4g] CSRF token input not found or has no value!");
} }
} else {
// Log if CSRF is not being added and why
console.log("[LOG 4h] Not adding CSRF token (not local HAPI or not modifying method). isUsingLocalHapi:", isUsingLocalHapi, "Method:", method);
} }
} else { } else {
// Ensure body is undefined if queryDef.requestBody is false
body = undefined; body = undefined;
} }
console.log("[LOG 5] Request body constructed. Body length:", body?.length ?? 'undefined'); // Check body length
// --- Log Request --- if (reqUrlOutput) reqUrlOutput.textContent = url;
if(reqUrlOutput) reqUrlOutput.textContent = url; if (curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body);
// <<< Log body right before generating cURL >>> console.log(`Executing: ${method} ${url}`);
console.log("[LOG 5a] Body variable before generateCurlCommand:", body ? body.substring(0,100)+'...' : body); console.log("Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
if(curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body); // Generate cURL if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
console.log(`Executing: ${method} ${url}`); console.log("Headers:", headers); if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
// --- Perform Fetch & Process Response ---
// --- Perform Fetch & Process Response ---
let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' }; let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' };
try { try {
console.log("[LOG 6] Initiating fetch...");
const resp = await fetch(url, { method, headers, body: (body || undefined) }); const resp = await fetch(url, { method, headers, body: (body || undefined) });
console.log("[LOG 7] Fetch completed. Status:", resp.status); respData.status = resp.status;
respData.statusText = resp.statusText;
respData.status = resp.status; respData.statusText = resp.statusText; respData.contentType = resp.headers.get('Content-Type') || ''; respData.contentType = resp.headers.get('Content-Type') || '';
console.log("[LOG 8] Reading response text...");
respData.text = await resp.text(); respData.text = await resp.text();
console.log("[LOG 9] Response text read. Length:", respData.text?.length); if (respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
if(respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
// Process body (includes OperationOutcome check) let isOperationOutcome = false;
console.log("[LOG 10] Processing response body..."); let operationOutcomeIssuesHtml = '';
let isOperationOutcome = false; let operationOutcomeIssuesHtml = ''; if (respData.text) {
if (respData.contentType.includes('json')) {
if (respData.text) { if (respData.contentType.includes('json')) { try { respData.json = JSON.parse(respData.text); try { respData.xml = jsonToFhirXml(respData.json); } catch(xmlConvErr){ respData.xml = `<error>XML conversion failed: ${xmlConvErr.message}</error>`; } if (respData.json.text?.div) respData.narrative = respData.json.text.div; if (respData.json.resourceType === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } } catch (e) { respData.json = { parsingError: e.message, rawText: respData.text }; respData.xml = `<data>${escapeXml(respData.text)}</data>`; } } else if (respData.contentType.includes('xml')) { respData.xml = respData.text; try { respData.json = JSON.parse(xmlToFhirJson(respData.xml)); const p = new DOMParser(), xd = p.parseFromString(respData.xml, "application/xml"), pe = xd.querySelector("parsererror"); if (pe) throw new Error(pe.textContent); if (xd.documentElement && xd.documentElement.tagName === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']"); if (nn) respData.narrative = nn.outerHTML; } catch(e) { respData.json = { parsingError: e.message, rawText: respData.text }; } } else { respData.json = { contentType: respData.contentType, content: respData.text }; respData.xml = `<data contentType="${escapeXml(respData.contentType)}">${escapeXml(respData.text)}</data>`; } } else if (resp.ok) { respData.json = { message: "Success (No Content)" }; respData.xml = jsonToFhirXml({}); } else { respData.json = { error: `Request failed: ${resp.status}`, detail: resp.statusText }; respData.xml = `<error>Request failed: ${resp.status} ${escapeXml(resp.statusText)}</error>`; } try {
console.log("[LOG 11] Response body processed. Is OperationOutcome:", isOperationOutcome); respData.json = JSON.parse(respData.text);
try { respData.xml = jsonToFhirXml(respData.json); } catch (xmlConvErr) { respData.xml = `<error>XML conversion failed: ${xmlConvErr.message}</error>`; }
// --- Update UI --- if (respData.json.text?.div) respData.narrative = respData.json.text.div;
block.dataset.responseData = JSON.stringify(respData); if (respData.json.resourceType === 'OperationOutcome') {
if(respFormatSelect) { respFormatSelect.style.display = 'inline-block'; respFormatSelect.disabled = false; } if(copyRespButton) copyRespButton.style.display = 'inline-block'; if(downloadRespButton) downloadRespButton.style.display = 'inline-block'; isOperationOutcome = true;
// Enhanced Error Display Logic operationOutcomeIssuesHtml = formatOperationOutcome(respData.json);
if (!resp.ok && isOperationOutcome && respNarrativeDiv) { }
console.log("[LOG 12a] Displaying formatted OperationOutcome."); } catch (e) {
respNarrativeDiv.innerHTML = operationOutcomeIssuesHtml; respNarrativeDiv.style.display = 'block'; respOutputPre.style.display = 'none'; respData.json = { parsingError: e.message, rawText: respData.text };
if (respFormatSelect) { const narrativeOpt = respFormatSelect.querySelector('option[value="narrative"]'); if (!narrativeOpt) { const opt = document.createElement('option'); opt.value = 'narrative'; opt.textContent = 'Formatted Issues'; respFormatSelect.insertBefore(opt, respFormatSelect.firstChild); } else { narrativeOpt.textContent = 'Formatted Issues'; narrativeOpt.disabled = false; } respFormatSelect.value = 'narrative'; respFormatSelect.dispatchEvent(new Event('change')); } respData.xml = `<data>${escapeXml(respData.text)}</data>`;
} else { }
console.log("[LOG 12b] Updating display format dropdown for success or non-OO error."); } else if (respData.contentType.includes('xml')) {
const narrativeOption = respFormatSelect?.querySelector('option[value="narrative"]'); if (narrativeOption) { narrativeOption.textContent = 'Narrative'; narrativeOption.disabled = !respData.narrative; } respData.xml = respData.text;
respFormatSelect.value = (respData.narrative && respFormatSelect.value === 'narrative') ? 'narrative' : 'json'; try {
respFormatSelect?.dispatchEvent(new Event('change')); // This will trigger highlighting via its listener respData.json = JSON.parse(xmlToFhirJson(respData.xml));
const p = new DOMParser(), xd = p.parseFromString(respData.xml, "application/xml"), pe = xd.querySelector("parsererror");
if (pe) throw new Error(pe.textContent);
if (xd.documentElement && xd.documentElement.tagName === 'OperationOutcome') {
isOperationOutcome = true;
operationOutcomeIssuesHtml = formatOperationOutcome(respData.json);
}
const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']");
if (nn) respData.narrative = nn.outerHTML;
} catch (e) {
respData.json = { parsingError: e.message, rawText: respData.text };
}
} else {
respData.json = { contentType: respData.contentType, content: respData.text };
respData.xml = `<data contentType="${escapeXml(respData.contentType)}">${escapeXml(respData.text)}</data>`;
}
} else if (resp.ok) {
respData.json = { message: "Success (No Content)" };
respData.xml = jsonToFhirXml({});
} else {
respData.json = { error: `Request failed: ${resp.status}`, detail: resp.statusText };
respData.xml = `<error>Request failed: ${resp.status} ${escapeXml(resp.statusText)}</error>`;
} }
console.log("[LOG 13] UI update triggered.");
} catch (e) { // Catch fetch/network/processing errors block.dataset.responseData = JSON.stringify(respData);
if (respFormatSelect) { respFormatSelect.style.display = 'inline-block'; respFormatSelect.disabled = false; }
if (copyRespButton) copyRespButton.style.display = 'inline-block';
if (downloadRespButton) downloadRespButton.style.display = 'inline-block';
if (!resp.ok && isOperationOutcome && respNarrativeDiv) {
respNarrativeDiv.innerHTML = operationOutcomeIssuesHtml;
respNarrativeDiv.style.display = 'block';
respOutputPre.style.display = 'none';
if (respFormatSelect) {
const narrativeOpt = respFormatSelect.querySelector('option[value="narrative"]');
if (!narrativeOpt) {
const opt = document.createElement('option');
opt.value = 'narrative';
opt.textContent = 'Formatted Issues';
respFormatSelect.insertBefore(opt, respFormatSelect.firstChild);
} else {
narrativeOpt.textContent = 'Formatted Issues';
narrativeOpt.disabled = false;
}
respFormatSelect.value = 'narrative';
respFormatSelect.dispatchEvent(new Event('change'));
}
} else {
const narrativeOption = respFormatSelect?.querySelector('option[value="narrative"]');
if (narrativeOption) { narrativeOption.textContent = 'Narrative'; narrativeOption.disabled = !respData.narrative; }
respFormatSelect.value = (respData.narrative && respFormatSelect.value === 'narrative') ? 'narrative' : 'json';
respFormatSelect?.dispatchEvent(new Event('change'));
}
} catch (e) {
console.error('[LOG 14] Error during fetch or response processing:', e); console.error('[LOG 14] Error during fetch or response processing:', e);
if(respStatusDiv) { respStatusDiv.textContent = `Error: ${e.message}`; respStatusDiv.style.color = 'red'; } if(respOutputCode) { respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; } if(respFormatSelect) respFormatSelect.style.display = 'none'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none'; if(respNarrativeDiv) respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block'; if (respStatusDiv) { respStatusDiv.textContent = `Error: ${e.message}`; respStatusDiv.style.color = 'red'; }
if (respOutputCode) { respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; }
if (respFormatSelect) respFormatSelect.style.display = 'none';
if (copyRespButton) copyRespButton.style.display = 'none';
if (downloadRespButton) downloadRespButton.style.display = 'none';
if (respNarrativeDiv) respNarrativeDiv.style.display = 'none';
if (respOutputPre) respOutputPre.style.display = 'block';
} finally { } finally {
console.log("[LOG 15] Executing finally block."); // <<< LOG 15 (Finally Block) console.log("[LOG 15] Executing finally block.");
executeButton.disabled = false; executeButton.disabled = false;
executeButton.textContent = 'Execute'; executeButton.textContent = 'Execute';
} }
}); // End executeButton listener });
} // End if (executeButton && ...) } // End if (executeButton && ...)
// Response Format Change Listener // Response Format Change Listener
@ -1545,63 +1711,95 @@ document.addEventListener('DOMContentLoaded', () => {
// --- Fetch Server Metadata (FIXED Local URL Handling) --- // --- Fetch Server Metadata (FIXED Local URL Handling) ---
if (fetchMetadataButton) { if (fetchMetadataButton) {
fetchMetadataButton.addEventListener('click', async () => { fetchMetadataButton.addEventListener('click', async () => {
// Clear previous results immediately console.log("[fetchMetadata] Button clicked");
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>'; if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block'; if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none'; // Hide old query list if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
fetchedMetadataCache = null; // Clear cache before fetch attempt fetchedMetadataCache = null;
availableSystemOperations = []; availableSystemOperations = [];
// Determine Base URL - FIXED
const customUrl = fhirServerUrlInput.value.trim().replace(/\/+$/, ''); const customUrl = fhirServerUrlInput.value.trim().replace(/\/+$/, '');
const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl; // Use '/fhir' proxy path if local const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl;
// Validate custom URL only if not using local HAPI if (!isUsingLocalHapi && !baseUrl) {
if (!isUsingLocalHapi && !baseUrl) { // Should only happen if customUrl is empty console.error("[fetchMetadata] Custom URL required");
fhirServerUrlInput.classList.add('is-invalid'); fhirServerUrlInput.classList.add('is-invalid');
alert('Please enter a valid FHIR server URL.'); alert('Please enter a valid FHIR server URL.');
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Custom URL required.</span>`; if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Custom URL required.</span>`;
return; return;
} }
// Basic format check for custom URL
if (!isUsingLocalHapi) { if (!isUsingLocalHapi) {
try { try { new URL(baseUrl); } catch (_) {
new URL(baseUrl); // Check if it's a parseable URL format console.error("[fetchMetadata] Invalid custom URL format");
} catch (_) {
fhirServerUrlInput.classList.add('is-invalid'); fhirServerUrlInput.classList.add('is-invalid');
alert('Invalid custom URL format. Please enter a valid URL (e.g., https://example.com/fhir).'); alert('Invalid custom URL format. Please enter a valid URL (e.g., https://example.com/fhir).');
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid custom URL format.</span>`; if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid custom URL format.</span>`;
return; return;
} }
const authType = authTypeSelect.value;
const bearerToken = bearerTokenInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (authType === 'bearer' && !bearerToken) {
console.error("[fetchMetadata] Bearer token required");
alert('Please enter a Bearer Token.');
bearerTokenInput.classList.add('is-invalid');
return;
}
if (authType === 'basic' && (!username || !password)) {
console.error("[fetchMetadata] Username and password required");
alert('Please enter both Username and Password for Basic Authentication.');
if (!username) usernameInput.classList.add('is-invalid');
if (!password) passwordInput.classList.add('is-invalid');
return;
}
} }
fhirServerUrlInput.classList.remove('is-invalid'); fhirServerUrlInput.classList.remove('is-invalid');
// Construct metadata URL (always add /metadata)
const url = `${baseUrl}/metadata`; const url = `${baseUrl}/metadata`;
const headers = { 'Accept': 'application/fhir+json' };
if (!isUsingLocalHapi) {
const authType = authTypeSelect.value;
const bearerToken = bearerTokenInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
console.log("[fetchMetadata] Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
console.log("[fetchMetadata] Adding header Authorization: Basic <redacted>");
}
}
console.log(`Fetching metadata from: ${url}`); console.log(`[fetchMetadata] Fetching from: ${url}`);
fetchMetadataButton.disabled = true; fetchMetadataButton.textContent = 'Fetching...'; fetchMetadataButton.disabled = true;
fetchMetadataButton.textContent = 'Fetching...';
try { try {
const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/fhir+json' } }); const resp = await fetch(url, { method: 'GET', headers });
if (!resp.ok) { const errText = await resp.text(); throw new Error(`HTTP ${resp.status} ${resp.statusText}: ${errText.substring(0, 500)}`); } if (!resp.ok) {
const errText = await resp.text();
throw new Error(`HTTP ${resp.status} ${resp.statusText}: ${errText.substring(0, 500)}`);
}
const data = await resp.json(); const data = await resp.json();
console.log('Metadata received:', data); console.log('[fetchMetadata] Metadata received:', data);
fetchedMetadataCache = data; // Cache successful fetch fetchedMetadataCache = data;
displayMetadataAndResourceButtons(data); // Parse and display displayMetadataAndResourceButtons(data);
} catch (e) { } catch (e) {
console.error('Metadata fetch error:', e); console.error('[fetchMetadata] Error:', e);
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error fetching metadata: ${e.message}</span>`; if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error fetching metadata: ${e.message}</span>`;
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block'; // Keep container visible to show error if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none'; if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
alert(`Error fetching metadata: ${e.message}`); alert(`Error fetching metadata: ${e.message}`);
fetchedMetadataCache = null; // Clear cache on error fetchedMetadataCache = null;
availableSystemOperations = []; availableSystemOperations = [];
} finally { } finally {
fetchMetadataButton.disabled = false; fetchMetadataButton.textContent = 'Fetch Metadata'; fetchMetadataButton.disabled = false;
fetchMetadataButton.textContent = 'Fetch Metadata';
} }
}); });
} else { } else {
console.error("Fetch Metadata button (#fetchMetadata) not found!"); console.error("Fetch Metadata button (#fetchMetadata) not found!");
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,45 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_form_helpers.html" import render_field %} {% from "_form_helpers.html" import render_field %}
{% block content %} {% block content %}
<div class="px-4 py-5 my-5 text-center"> <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"> <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">Import FHIR Implementation Guides</h1> <h1 class="display-5 fw-bold text-body-emphasis">Import FHIR Implementation Guides</h1>
<div class="col-lg-6 mx-auto"> <div class="col-lg-6 mx-auto">
<p class="lead mb-4"> <p class="lead mb-4">
Import new FHIR Implementation Guides to the system for viewing. Import new FHIR Implementation Guides to the system for viewing.
</p> </p>
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">View Downloaded IGs</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
</div> </div>
-----------------------------------------------------------------remove the buttons-----------------------------------------------------> </div>
<div id="fire-loading-overlay" class="fire-loading-overlay d-none">
<div class="campfire">
<div class="campfire-scaler">
<div class="sparks">
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
<div class="spark"></div> <div class="spark"></div> <div class="spark"></div> <div class="spark"></div>
</div>
<div class="logs">
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
<div class="log"><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div><div class="streak"></div></div>
</div>
<div class="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> <div class="loading-text text-center">
<p class="text-white mt-3">Importing Implementation Guide... Please wait.</p>
<p id="log-display" class="text-white mb-0"></p>
</div> </div>
</div> </div>
@ -26,21 +49,13 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<form method="POST" class="form"> <form id="import-ig-form" method="POST" class="form">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.package_name) }} {{ render_field(form.package_name) }}
{{ render_field(form.package_version) }} {{ render_field(form.package_version) }}
<!-- Dependency Pulling Mode Toggle --> {{ render_field(form.dependency_mode) }}
<div class="mb-3">
<label for="dependency_mode" class="form-label">Dependency Pulling Mode:</label>
<select class="form-select" id="dependency_mode" name="dependency_mode">
<option value="recursive" selected>Current Recursive</option>
<option value="patch-canonical">Patch Canonical Versions</option>
<option value="tree-shaking">Tree Shaking (Only Used Dependencies)</option>
</select>
</div>
<div class="d-grid gap-2 d-sm-flex"> <div class="d-grid gap-2 d-sm-flex">
{{ form.submit(class="btn btn-success") }} {{ form.submit(class="btn btn-success", id="submit-btn") }}
<a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a> <a href="{{ url_for('index') }}" class="btn btn-secondary">Back</a>
</div> </div>
</form> </form>
@ -49,4 +64,82 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Your existing JS here (exactly as provided before)
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('import-ig-form');
const submitBtn = document.getElementById('submit-btn');
const loadingOverlay = document.getElementById('fire-loading-overlay');
const logDisplay = document.getElementById('log-display');
if (form && submitBtn && loadingOverlay && logDisplay) {
form.addEventListener('submit', async function(event) {
event.preventDefault();
submitBtn.disabled = true;
logDisplay.textContent = 'Starting import...';
loadingOverlay.classList.remove('d-none'); // Show the overlay
let eventSource;
try {
eventSource = new EventSource('{{ url_for('stream_import_logs') }}');
eventSource.onmessage = function(event) {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
logDisplay.textContent = event.data;
};
eventSource.onerror = function() {
console.error('SSE connection error');
if(eventSource) eventSource.close();
logDisplay.textContent = 'Log streaming interrupted.';
};
} catch (e) {
console.error('Failed to initialize SSE:', e);
logDisplay.textContent = 'Unable to stream logs: ' + e.message;
}
const formData = new FormData(form);
try {
const response = await fetch('{{ url_for('import_ig') }}', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
});
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
eventSource.close();
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP error: ${response.status}` }));
throw new Error(errorData.message || `HTTP error: ${response.status}`);
}
const data = await response.json();
if (data.redirect) {
window.location.href = data.redirect;
} else {
// Optional: display success message
}
} catch (error) {
console.error('Import error:', error);
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
eventSource.close();
}
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-danger mt-3';
alertDiv.textContent = `Import failed: ${error.message}`;
form.closest('.card-body').appendChild(alertDiv);
} finally {
loadingOverlay.classList.add('d-none'); // Hide overlay when done
submitBtn.disabled = false;
}
});
} else {
console.error('Required elements missing for import script.');
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,24 +1,168 @@
{% 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">
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE Ig Toolkit" width="512" height="512"> <div id="logo-container" class="mb-4">
<img class="d-block mx-auto" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE IG Toolkit" width="256" height="256">
</div>
<div id="animation-container" class="mb-4 d-none">
<div class="section-center">
<div class="moon">
<div></div>
<div></div>
<div></div>
</div>
<div class="shooting-star"></div>
<div class="shooting-star-2"></div>
<div class="star"></div>
<div class="star snd"></div>
<div class="star trd"></div>
<div class="star fth"></div>
<div class="star fith"></div>
<div class="circle"></div>
<div class="wood-circle"></div>
<div class="wood"></div>
<div class="tree-1"></div>
<div class="tree-2"></div>
<div class="fire">
<span></span>
<span></span>
<span></span>
</div>
<div class="smoke">
<span class="s-0"></span>
<span class="s-1"></span>
<span class="s-2"></span>
<span class="s-3"></span>
<span class="s-4"></span>
<span class="s-5"></span>
<span class="s-6"></span>
<span class="s-7"></span>
<span class="s-8"></span>
<span class="s-9"></span>
</div>
</div>
</div>
<h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1> <h1 class="display-5 fw-bold text-body-emphasis">Welcome to {{ site_name }}</h1>
<div class="col-lg-6 mx-auto"> <div class="col-lg-6 mx-auto">
<p class="lead mb-4"> <p class="lead mb-2">Simple tool for importing, viewing, and validating FHIR Implementation Guides.</p>
Simple tool for importing, viewing, and validating FHIR Implementation Guides. <p class="text-muted">Streamline Your FHIR Workflow</p>
</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center flex-wrap">
<a href="{{ url_for('import_ig') }}" class="btn btn-primary btn-lg px-4 gap-3 mb-2">Import FHIR IG</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">Push IGs</a>
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">Upload Test Data</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">Validate FHIR Sample</a>
<a href="{{ url_for('fhir_ui') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">FHIR API Explorer</a>
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">FHIR UI Operations</a>
<a href="{{ url_for('fsh_converter') }}" class="btn btn-outline-secondary btn-lg px-4 mb-2">FSH Converter</a>
<a href="{{ url_for('about') }}" class="btn btn-outline-info btn-lg px-4 mb-2">About</a>
</div>
</div> </div>
</div> </div>
<div class="container">
<div class="row g-4">
<!-- IG Management -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">IG Management</h5>
<p class="card-text">Import and manage FHIR Implementation Guides.</p>
<div class="d-grid gap-2">
<!-- <a href="{{ url_for('import_ig') }}" class="btn btn-primary">Import FHIR IG</a> -->
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary">Search & Import IGs</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary">Manage FHIR Packages</a>
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary">Push IGs</a>
</div>
</div>
</div>
</div>
<!-- Validation & Testing -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Validation & Testing</h5>
<p class="card-text">Validate and test FHIR resources.</p>
<div class="d-grid gap-2">
<a href="{{ url_for('upload_test_data') }}" class="btn btn-outline-secondary">Upload Test Data</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary">Validate FHIR Sample</a>
<a href="{{ url_for('retrieve_split_data') }}" class="btn btn-outline-secondary">Retrieve & Split Data</a>
</div>
</div>
</div>
</div>
<!-- API & Tools -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">API & Tools</h5>
<p class="card-text">Explore FHIR APIs and convert resources.</p>
<div class="d-grid gap-2">
<a href="{{ url_for('fhir_ui') }}" class="btn btn-outline-secondary">FHIR API Explorer</a>
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary">FHIR UI Operations</a>
<a href="{{ url_for('fsh_converter') }}" class="btn btn-outline-secondary">FSH Converter</a>
</div>
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<a href="{{ url_for('about') }}" class="btn btn-outline-info">About FHIRFLARE</a>
</div>
</div>
<style>
#logo-container {
animation: fadeOut 30s forwards;
}
@keyframes fadeOut {
0% { opacity: 1; }
95% { opacity: 1; }
100% { opacity: 0; display: none; }
}
#animation-container {
transition: opacity 1s ease;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const logoContainer = document.getElementById('logo-container');
const animationContainer = document.getElementById('animation-container');
const htmlElement = document.documentElement;
// After 30 seconds, fade out logo and show animation
setTimeout(() => {
logoContainer.style.display = 'none';
animationContainer.classList.remove('d-none');
animationContainer.style.opacity = '1';
}, 30000);
// Sync animation with theme (reversed: dark = fire-on, light = fire-off)
const updateAnimationState = () => {
const savedTheme = localStorage.getItem('theme') || (document.cookie.includes('theme=dark') ? 'dark' : 'light');
const isDark = savedTheme === 'dark';
document.body.classList.toggle('fire-on', isDark); // Dark theme: fire-on, Light theme: fire-off
};
// Initial state
updateAnimationState();
// Override toggleTheme to ensure animation updates
const originalToggleTheme = window.toggleTheme || function() {};
window.toggleTheme = function() {
originalToggleTheme();
updateAnimationState();
};
// Observe changes to data-theme attribute
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
updateAnimationState();
}
});
});
observer.observe(htmlElement, { attributes: true });
// Clean up observer on page unload
window.addEventListener('unload', () => {
observer.disconnect();
});
});
</script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,690 @@
{% extends "base.html" %}
{% from "_form_helpers.html" import render_field %}
{% block content %}
<div class="px-4 py-5 my-5 text-center">
<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>
{{ 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>
{# Authentication Section (Shown for Custom URL) #}
<div class="mb-3" id="authSection" style="display: none;">
<label class="form-label fw-bold">Authentication</label>
<div class="row g-3 align-items-end">
<div class="col-md-5">
<select class="form-select" id="authType" name="auth_type">
<option value="none" selected>None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Authentication</option>
</select>
</div>
<div class="col-md-7" id="authInputsGroup" style="display: none;">
{# Bearer Token Input #}
<div id="bearerTokenInput" style="display: none;">
<label for="bearerToken" class="form-label">Bearer Token</label>
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
</div>
{# Basic Auth Inputs #}
<div id="basicAuthInputs" style="display: none;">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
</div>
</div>
</div>
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
</div>
{# Checkbox Row #}
<div class="row g-3 mb-3 align-items-center">
<div class="col-md-6">
{{ render_field(form.validate_references, id='validate_references_checkbox') }}
</div>
<div class="col-md-6" id="fetchReferenceBundlesGroup" style="display: none;">
{{ render_field(form.fetch_reference_bundles, id='fetch_reference_bundles_checkbox') }}
</div>
</div>
<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>
<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_field(form.split_bundle_zip, class="form-control") }}
<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');
const authSection = document.getElementById('authSection');
const authTypeSelect = document.getElementById('authType');
const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
// --- State & Config Variables ---
const appMode = '{{ app_mode | default("standalone") | lower }}';
const apiKey = '{{ api_key }}';
const sessionZipPath = '{{ session.get("retrieve_params", {}).get("bundle_zip_path", "") }}';
let useLocalHapi = (appMode !== 'lite');
let retrieveZipPath = null;
let splitZipPath = null;
let fetchedMetadataCache = null;
// --- Helper Functions ---
const sanitizeText = (str) => str ? String(str).replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
// --- UI Update Functions ---
function updateBundleSourceUI() {
const selectedSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
if (splitBundleZipGroup) {
splitBundleZipGroup.style.display = selectedSource === 'upload' ? 'block' : 'none';
}
if (splitBundleZipInput) {
splitBundleZipInput.required = selectedSource === 'upload';
}
}
function updateServerToggleUI() {
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) return;
if (appMode === 'lite') {
useLocalHapi = false;
toggleServerButton.disabled = true;
toggleServerButton.classList.add('disabled');
toggleServerButton.style.pointerEvents = 'none';
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;
authSection.style.display = 'block';
} 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;
authSection.style.display = useLocalHapi ? 'none' : 'block';
}
fhirServerUrlInput.classList.remove('is-invalid');
updateAuthInputsUI();
console.log(`Server toggle UI updated: useLocalHapi=${useLocalHapi}, customUrl=${fhirServerUrlInput.value}`);
}
function updateAuthInputsUI() {
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
const authType = authTypeSelect.value;
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
if (authType !== 'basic' && passwordInput) passwordInput.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 (authTypeSelect) {
authTypeSelect.addEventListener('change', updateAuthInputsUI);
}
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';
const headers = { 'Accept': 'application/fhir+json' };
if (!useLocalHapi && customUrl) {
headers['X-Target-FHIR-Server'] = customUrl;
if (authTypeSelect && authTypeSelect.value !== 'none') {
const authType = authTypeSelect.value;
if (authType === 'bearer' && bearerTokenInput && bearerTokenInput.value) {
headers['Authorization'] = `Bearer ${bearerTokenInput.value}`;
} else if (authType === 'basic' && usernameInput && passwordInput && usernameInput.value && passwordInput.value) {
const credentials = btoa(`${usernameInput.value}:${passwordInput.value}`);
headers['Authorization'] = `Basic ${credentials}`;
}
}
console.log(`Fetching 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);
// Add authentication fields for custom URL
if (!useLocalHapi && authTypeSelect) {
const authType = authTypeSelect.value;
formData.append('auth_type', authType);
if (authType === 'bearer' && bearerTokenInput) {
if (!bearerTokenInput.value) {
alert('Please enter a Bearer Token.');
retrieveButton.disabled = false;
if (spinner) spinner.style.display = 'none';
if (icon) icon.style.display = 'inline-block';
return;
}
formData.append('bearer_token', bearerTokenInput.value);
} else if (authType === 'basic' && usernameInput && passwordInput) {
if (!usernameInput.value || !passwordInput.value) {
alert('Please enter both Username and Password for Basic Authentication.');
retrieveButton.disabled = false;
if (spinner) spinner.style.display = 'none';
if (icon) icon.style.display = 'inline-block';
return;
}
formData.append('username', usernameInput.value);
formData.append('password', passwordInput.value);
}
}
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')}, AuthType: ${formData.get('auth_type')}`);
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 */ }
} 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) {
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();
});
</script>
{% endblock %}

View File

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

View File

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_form_helpers.html" import render_field %} {# Assuming you have this helper #} {% from "_form_helpers.html" import render_field %}
{% block content %} {% block content %}
<div class="px-4 py-5 my-5 text-center"> <div class="px-4 py-5 my-5 text-center">
@ -20,7 +20,6 @@
<h4 class="my-0 fw-normal"><i class="bi bi-cloud-upload me-2"></i>Upload Configuration</h4> <h4 class="my-0 fw-normal"><i class="bi bi-cloud-upload me-2"></i>Upload Configuration</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
{# --- Display WTForms validation errors --- #}
{% if form.errors %} {% if form.errors %}
<div class="alert alert-danger"> <div class="alert alert-danger">
<p><strong>Please correct the following errors:</strong></p> <p><strong>Please correct the following errors:</strong></p>
@ -31,57 +30,52 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{# --- End Error Display --- #}
<form id="uploadTestDataForm" method="POST" enctype="multipart/form-data"> {# Use POST, JS will handle submission #} <form id="uploadTestDataForm" method="POST" enctype="multipart/form-data">
{{ form.csrf_token }} {# Render CSRF token #} {{ form.csrf_token }}
{{ render_field(form.fhir_server_url, class="form-control form-control-lg") }} {{ render_field(form.fhir_server_url, class="form-control form-control-lg") }}
{# Authentication Row #}
<div class="row g-3 mb-3 align-items-end"> <div class="row g-3 mb-3 align-items-end">
<div class="col-md-5"> <div class="col-md-5">
{{ render_field(form.auth_type, class="form-select") }} {{ render_field(form.auth_type, class="form-select") }}
</div> </div>
<div class="col-md-7" id="authTokenGroup" style="display: none;"> <div class="col-md-7" id="authInputsGroup" style="display: none;">
<div id="bearerTokenInput" style="display: none;">
{{ render_field(form.auth_token, class="form-control") }} {{ render_field(form.auth_token, class="form-control") }}
</div> </div>
<div id="basicAuthInputs" style="display: none;">
{{ render_field(form.username, class="form-control mb-2", placeholder="Username") }}
{{ render_field(form.password, class="form-control", placeholder="Password", type="password") }}
</div>
</div>
</div> </div>
{# File Input #}
{{ render_field(form.test_data_file, class="form-control") }} {{ render_field(form.test_data_file, class="form-control") }}
<small class="form-text text-muted">Select one or more .json, .xml files, or a single .zip file containing them.</small> <small class="form-text text-muted">Select one or more .json, .xml files, or a single .zip file containing them.</small>
{# --- Validation Options --- #}
<div class="row g-3 mt-3 mb-3 align-items-center"> <div class="row g-3 mt-3 mb-3 align-items-center">
<div class="col-md-6"> <div class="col-md-6">
{# Render BooleanField using the macro #}
{{ render_field(form.validate_before_upload) }} {{ render_field(form.validate_before_upload) }}
<small class="form-text text-muted">It is suggested to not validate against more than 500 files</small> <small class="form-text text-muted">It is suggested to not validate against more than 500 files</small>
</div> </div>
<div class="col-md-6" id="validationPackageGroup" style="display: none;"> <div class="col-md-6" id="validationPackageGroup" style="display: none;">
{# Render SelectField using the macro #}
{{ render_field(form.validation_package_id, class="form-select") }} {{ render_field(form.validation_package_id, class="form-select") }}
</div> </div>
</div> </div>
{# --- END Validation Options --- #}
{# Upload Mode/Error Handling/Conditional Row #}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-4"> <div class="col-md-4">
{{ render_field(form.upload_mode, class="form-select") }} {{ render_field(form.upload_mode, class="form-select") }}
</div> </div>
{# --- Conditional Upload Checkbox --- #} <div class="col-md-4 d-flex align-items-end">
<div class="col-md-4 d-flex align-items-end"> {# Use flex alignment #}
{{ render_field(form.use_conditional_uploads) }} {{ render_field(form.use_conditional_uploads) }}
</div> </div>
{# --- END --- #}
<div class="col-md-4"> <div class="col-md-4">
{{ render_field(form.error_handling, class="form-select") }} {{ render_field(form.error_handling, class="form-select") }}
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="uploadButton"> <button type="submit" class="btn btn-primary btn-lg w-100" id="uploadButton">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
<i class="bi bi-arrow-up-circle me-2"></i>Upload and Process <i class="bi bi-arrow-up-circle me-2"></i>Upload and Process
@ -90,31 +84,26 @@
</div> </div>
</div> </div>
{# Results Area #}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header"> <div class="card-header">
<h4 class="my-0 fw-normal"><i class="bi bi-terminal me-2"></i>Processing Log & Results</h4> <h4 class="my-0 fw-normal"><i class="bi bi-terminal me-2"></i>Processing Log & Results</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
{# Live Console for Streaming Output #}
<div id="liveConsole" class="border p-3 rounded bg-dark text-light mb-3" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;"> <div id="liveConsole" class="border p-3 rounded bg-dark text-light mb-3" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
<span class="text-muted">Processing output will appear here...</span> <span class="text-muted">Processing output will appear here...</span>
</div> </div>
{# Final Summary Report Area #}
<div id="uploadResponse" class="mt-3"> <div id="uploadResponse" class="mt-3">
{# Final summary message appears here #}
</div> </div>
</div> </div>
</div> </div>
</div>
</div> {# End Col #} </div>
</div> {# End Row #} </div>
</div> {# End Container #}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {# Include scripts from base.html #} {{ super() }}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('uploadTestDataForm'); const form = document.getElementById('uploadTestDataForm');
@ -123,64 +112,74 @@ document.addEventListener('DOMContentLoaded', function() {
const liveConsole = document.getElementById('liveConsole'); const liveConsole = document.getElementById('liveConsole');
const responseDiv = document.getElementById('uploadResponse'); const responseDiv = document.getElementById('uploadResponse');
const authTypeSelect = document.getElementById('auth_type'); const authTypeSelect = document.getElementById('auth_type');
const authTokenGroup = document.getElementById('authTokenGroup'); const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerTokenInput');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const authTokenInput = document.getElementById('auth_token'); const authTokenInput = document.getElementById('auth_token');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const fileInput = document.getElementById('test_data_file'); const fileInput = document.getElementById('test_data_file');
const validateCheckbox = document.getElementById('validate_before_upload'); const validateCheckbox = document.getElementById('validate_before_upload');
const validationPackageGroup = document.getElementById('validationPackageGroup'); const validationPackageGroup = document.getElementById('validationPackageGroup');
const validationPackageSelect = document.getElementById('validation_package_id'); const validationPackageSelect = document.getElementById('validation_package_id');
const uploadModeSelect = document.getElementById('upload_mode'); // Get upload mode select const uploadModeSelect = document.getElementById('upload_mode');
const conditionalUploadCheckbox = document.getElementById('use_conditional_uploads'); // Get conditional checkbox const conditionalUploadCheckbox = document.getElementById('use_conditional_uploads');
// --- Helper: Sanitize text ---
const sanitizeText = (str) => str ? String(str).replace(/</g, "&lt;").replace(/>/g, "&gt;") : ""; const sanitizeText = (str) => str ? String(str).replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
// --- Event Listener: Show/Hide Auth Token --- if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
if (authTypeSelect && authTokenGroup) {
authTypeSelect.addEventListener('change', function() { authTypeSelect.addEventListener('change', function() {
authTokenGroup.style.display = this.value === 'bearerToken' ? 'block' : 'none'; authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = ''; if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
}); });
authTokenGroup.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none'; // Initial state authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
} else { console.error("Auth elements not found."); } bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
} else {
console.error("Auth elements not found.");
}
// --- Event Listener: Show/Hide Validation Package Dropdown ---
if (validateCheckbox && validationPackageGroup) { if (validateCheckbox && validationPackageGroup) {
const toggleValidationPackage = () => { const toggleValidationPackage = () => {
validationPackageGroup.style.display = validateCheckbox.checked ? 'block' : 'none'; validationPackageGroup.style.display = validateCheckbox.checked ? 'block' : 'none';
}; };
validateCheckbox.addEventListener('change', toggleValidationPackage); validateCheckbox.addEventListener('change', toggleValidationPackage);
toggleValidationPackage(); // Initial state toggleValidationPackage();
} else { console.error("Validation checkbox or package group not found."); } } else {
console.error("Validation checkbox or package group not found.");
}
// --- Event Listener: Enable/Disable Conditional Upload Checkbox ---
if (uploadModeSelect && conditionalUploadCheckbox) { if (uploadModeSelect && conditionalUploadCheckbox) {
const toggleConditionalCheckbox = () => { const toggleConditionalCheckbox = () => {
// Enable checkbox only if mode is 'individual'
conditionalUploadCheckbox.disabled = (uploadModeSelect.value !== 'individual'); conditionalUploadCheckbox.disabled = (uploadModeSelect.value !== 'individual');
// Optional: Uncheck if disabled
if (conditionalUploadCheckbox.disabled) { if (conditionalUploadCheckbox.disabled) {
conditionalUploadCheckbox.checked = false; conditionalUploadCheckbox.checked = false;
} }
}; };
uploadModeSelect.addEventListener('change', toggleConditionalCheckbox); uploadModeSelect.addEventListener('change', toggleConditionalCheckbox);
toggleConditionalCheckbox(); // Initial state toggleConditionalCheckbox();
} else { console.error("Upload mode select or conditional upload checkbox not found."); } } else {
console.error("Upload mode select or conditional upload checkbox not found.");
}
// --- Event Listener: Form Submission ---
if (form && uploadButton && spinner && liveConsole && responseDiv && fileInput && validateCheckbox && validationPackageSelect && conditionalUploadCheckbox) { if (form && uploadButton && spinner && liveConsole && responseDiv && fileInput && validateCheckbox && validationPackageSelect && conditionalUploadCheckbox) {
form.addEventListener('submit', async function(event) { form.addEventListener('submit', async function(event) {
event.preventDefault(); event.preventDefault();
console.log("Form submitted"); console.log("Form submitted");
// Basic validation
if (!fileInput.files || fileInput.files.length === 0) { alert('Please select at least one file.'); return; } if (!fileInput.files || fileInput.files.length === 0) { alert('Please select at least one file.'); return; }
const fhirServerUrl = document.getElementById('fhir_server_url').value.trim(); const fhirServerUrl = document.getElementById('fhir_server_url').value.trim();
if (!fhirServerUrl) { alert('Please enter the Target FHIR Server URL.'); return; } if (!fhirServerUrl) { alert('Please enter the Target FHIR Server URL.'); return; }
if (validateCheckbox.checked && !validationPackageSelect.value) { alert('Please select a package for validation.'); return; } if (validateCheckbox.checked && !validationPackageSelect.value) { alert('Please select a package for validation.'); return; }
if (authTypeSelect.value === 'basic') {
if (!usernameInput.value.trim()) { alert('Please enter a username for Basic Authentication.'); return; }
if (!passwordInput.value) { alert('Please enter a password for Basic Authentication.'); return; }
}
// UI Updates
uploadButton.disabled = true; uploadButton.disabled = true;
if(spinner) spinner.style.display = 'inline-block'; if(spinner) spinner.style.display = 'inline-block';
const uploadIcon = uploadButton.querySelector('i'); const uploadIcon = uploadButton.querySelector('i');
@ -188,27 +187,30 @@ document.addEventListener('DOMContentLoaded', function() {
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting upload process...</div>`; liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting upload process...</div>`;
responseDiv.innerHTML = ''; responseDiv.innerHTML = '';
// Prepare FormData
const formData = new FormData(); const formData = new FormData();
formData.append('fhir_server_url', fhirServerUrl); formData.append('fhir_server_url', fhirServerUrl);
formData.append('auth_type', authTypeSelect.value); formData.append('auth_type', authTypeSelect.value);
if (authTypeSelect.value === 'bearerToken' && authTokenInput) { formData.append('auth_token', authTokenInput.value); } if (authTypeSelect.value === 'bearerToken' && authTokenInput) {
formData.append('auth_token', authTokenInput.value);
} else if (authTypeSelect.value === 'basic') {
formData.append('username', usernameInput.value);
formData.append('password', passwordInput.value);
}
formData.append('upload_mode', uploadModeSelect.value); formData.append('upload_mode', uploadModeSelect.value);
formData.append('error_handling', document.getElementById('error_handling').value); formData.append('error_handling', document.getElementById('error_handling').value);
formData.append('validate_before_upload', validateCheckbox.checked ? 'true' : 'false'); formData.append('validate_before_upload', validateCheckbox.checked ? 'true' : 'false');
if (validateCheckbox.checked) { formData.append('validation_package_id', validationPackageSelect.value); } if (validateCheckbox.checked) { formData.append('validation_package_id', validationPackageSelect.value); }
formData.append('use_conditional_uploads', conditionalUploadCheckbox.checked ? 'true' : 'false'); // Add new field formData.append('use_conditional_uploads', conditionalUploadCheckbox.checked ? 'true' : 'false');
// Append files for (let i = 0; i < fileInput.files.length; i++) {
for (let i = 0; i < fileInput.files.length; i++) { formData.append('test_data_files', fileInput.files[i]); } formData.append('test_data_files', fileInput.files[i]);
}
// CSRF token and API key
const csrfTokenInput = form.querySelector('input[name="csrf_token"]'); const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
const csrfToken = csrfTokenInput ? csrfTokenInput.value : ""; const csrfToken = csrfTokenInput ? csrfTokenInput.value : "";
const internalApiKey = {{ api_key | default("") | tojson }}; const internalApiKey = {{ api_key | default("") | tojson }};
try { try {
// --- API Call ---
const response = await fetch('/api/upload-test-data', { const response = await fetch('/api/upload-test-data', {
method: 'POST', method: 'POST',
headers: { 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey }, headers: { 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
@ -222,7 +224,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
if (!response.body) { throw new Error("Response body missing."); } if (!response.body) { throw new Error("Response body missing."); }
// --- Process Streaming Response ---
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
@ -238,7 +239,6 @@ document.addEventListener('DOMContentLoaded', function() {
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString(); const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
let messageClass = 'text-info'; let prefix = '[INFO]'; let messageClass = 'text-info'; let prefix = '[INFO]';
// Handle message types including validation
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; } if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; } else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; } else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
@ -248,14 +248,12 @@ document.addEventListener('DOMContentLoaded', function() {
else if (data.type === 'validation_warning') { prefix = '[VALIDATION]'; messageClass = 'text-warning'; } else if (data.type === 'validation_warning') { prefix = '[VALIDATION]'; messageClass = 'text-warning'; }
else if (data.type === 'validation_error') { prefix = '[VALIDATION]'; messageClass = 'text-danger'; } else if (data.type === 'validation_error') { prefix = '[VALIDATION]'; messageClass = 'text-danger'; }
// Append to console
const messageDiv = document.createElement('div'); messageDiv.className = messageClass; const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
let messageText = sanitizeText(data.message) || '...'; let messageText = sanitizeText(data.message) || '...';
if (data.details) { messageText += ` <small>(${sanitizeText(data.details)})</small>`; } if (data.details) { messageText += ` <small>(${sanitizeText(data.details)})</small>`; }
messageDiv.innerHTML = `${timestamp} ${prefix} ${messageText}`; messageDiv.innerHTML = `${timestamp} ${prefix} ${messageText}`;
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight; liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
// Handle final summary
if (data.type === 'complete' && data.data) { if (data.type === 'complete' && data.data) {
const summary = data.data; const summary = data.data;
let alertClass = 'alert-secondary'; let alertClass = 'alert-secondary';
@ -276,16 +274,15 @@ document.addEventListener('DOMContentLoaded', function() {
${summary.errors?.length > 0 ? '<br><strong>Details:</strong><ul>' + summary.errors.map(e => `<li>${sanitizeText(e)}</li>`).join('') + '</ul>' : ''} ${summary.errors?.length > 0 ? '<br><strong>Details:</strong><ul>' + summary.errors.map(e => `<li>${sanitizeText(e)}</li>`).join('') + '</ul>' : ''}
</div>`; </div>`;
} }
} catch (parseError) { console.error('Stream parse error:', parseError, 'Line:', line); /* ... log error to console ... */ } } catch (parseError) { console.error('Stream parse error:', parseError, 'Line:', line); }
} // end for line }
} // end while }
// Process final buffer (if needed)
if (buffer.trim()) { if (buffer.trim()) {
try { try {
const data = JSON.parse(buffer.trim()); const data = JSON.parse(buffer.trim());
if (data.type === 'complete' && data.data) { /* ... update summary ... */ } if (data.type === 'complete' && data.data) { /* ... update summary ... */ }
} catch (parseError) { console.error('Final buffer parse error:', parseError); /* ... log error to console ... */ } } catch (parseError) { console.error('Final buffer parse error:', parseError); }
} }
} catch (error) { } catch (error) {
@ -293,15 +290,13 @@ document.addEventListener('DOMContentLoaded', function() {
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message)}</div>`; responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message)}</div>`;
const ts = new Date().toLocaleTimeString(); const errDiv = document.createElement('div'); errDiv.className = 'text-danger'; errDiv.textContent = `${ts} [CLIENT_ERROR] ${sanitizeText(error.message)}`; liveConsole.appendChild(errDiv); const ts = new Date().toLocaleTimeString(); const errDiv = document.createElement('div'); errDiv.className = 'text-danger'; errDiv.textContent = `${ts} [CLIENT_ERROR] ${sanitizeText(error.message)}`; liveConsole.appendChild(errDiv);
} finally { } finally {
// Re-enable button
uploadButton.disabled = false; uploadButton.disabled = false;
if(spinner) spinner.style.display = 'none'; if(spinner) spinner.style.display = 'none';
const uploadIcon = uploadButton.querySelector('i'); const uploadIcon = uploadButton.querySelector('i');
if (uploadIcon) uploadIcon.style.display = 'inline-block'; // Show icon if (uploadIcon) uploadIcon.style.display = 'inline-block';
} }
}); // End submit listener });
} else { console.error("Could not find all required elements for form submission."); } } else { console.error("Could not find all required elements for form submission."); }
});
}); // End DOMContentLoaded
</script> </script>
{% endblock %} {% endblock %}