diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2057f75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cql_uploads/ \ No newline at end of file diff --git a/cmd/cqlplay/main.go b/cmd/cqlplay/main.go index 306f6f6..628c381 100644 --- a/cmd/cqlplay/main.go +++ b/cmd/cqlplay/main.go @@ -24,6 +24,10 @@ import ( "io" "io/fs" "net/http" + "os" + "path/filepath" + "strings" + "sync" "time" "flag" @@ -50,7 +54,18 @@ func main() { // tp is a shared terminology provider. This must be thread safe. var tp *terminology.LocalFHIRProvider +// File storage directory +const uploadDir = ".cql_uploads" + +// Mutex for file operations +var fileMutex sync.Mutex + func serve() error { + // Create upload directory if it doesn't exist + if err := os.MkdirAll(uploadDir, 0755); err != nil { + return fmt.Errorf("failed to create upload directory: %w", err) + } + mux, err := serverHandler() if err != nil { return err @@ -77,10 +92,185 @@ func serverHandler() (http.Handler, error) { // eval_cql is the evaluation endpoint for CQL. mux.HandleFunc("/eval_cql", handleEvalCQL) + + // File management endpoints + mux.HandleFunc("/upload_file", handleUploadFile) + mux.HandleFunc("/delete_file", handleDeleteFile) + mux.HandleFunc("/list_files", handleListFiles) + + // Health check endpoint + mux.HandleFunc("/health", handleHealthCheck) return mux, nil } +// Type definitions for file management +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +type ListFilesResponse struct { + Files []FileInfo `json:"files"` +} + +type DeleteFileRequest struct { + Filename string `json:"filename"` +} + +// handleUploadFile handles file uploads +func handleUploadFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse the multipart form, 10 MB max + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest) + return + } + + // Get the file from the form + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "Failed to get file: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + // Check file extension + if !strings.HasSuffix(strings.ToLower(header.Filename), ".cql") { + http.Error(w, "Only .cql files are allowed", http.StatusBadRequest) + return + } + + // Create a new file in the uploads directory + fileMutex.Lock() + defer fileMutex.Unlock() + + filePath := filepath.Join(uploadDir, header.Filename) + + // Create the file + dst, err := os.Create(filePath) + if err != nil { + http.Error(w, "Failed to create file: "+err.Error(), http.StatusInternalServerError) + return + } + defer dst.Close() + + // Copy the uploaded file to the destination file + if _, err := io.Copy(dst, file); err != nil { + http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError) + return + } + + // Get file info for response + fileInfo, err := os.Stat(filePath) + if err != nil { + http.Error(w, "Failed to get file info: "+err.Error(), http.StatusInternalServerError) + return + } + + // Return the file size in the response + response := map[string]int64{ + "size": fileInfo.Size(), + } + + // Send response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// handleDeleteFile handles file deletion +func handleDeleteFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req DeleteFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest) + return + } + + if req.Filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + // Prevent directory traversal + filename := filepath.Base(req.Filename) + filePath := filepath.Join(uploadDir, filename) + + fileMutex.Lock() + defer fileMutex.Unlock() + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + // Delete the file + if err := os.Remove(filePath); err != nil { + http.Error(w, "Failed to delete file: "+err.Error(), http.StatusInternalServerError) + return + } + + // Send success response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} + +// handleListFiles handles listing all uploaded files +func handleListFiles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + fileMutex.Lock() + defer fileMutex.Unlock() + + files, err := os.ReadDir(uploadDir) + if err != nil { + http.Error(w, "Failed to read directory: "+err.Error(), http.StatusInternalServerError) + return + } + + var fileInfos []FileInfo + for _, file := range files { + // Skip directories + if file.IsDir() { + continue + } + + // Get file info + info, err := file.Info() + if err != nil { + log.Errorf("Failed to get info for file %s: %v", file.Name(), err) + continue + } + + fileInfos = append(fileInfos, FileInfo{ + Name: info.Name(), + Size: info.Size(), + }) + } + + // Send response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ListFilesResponse{Files: fileInfos}) +} + +// handleHealthCheck handles health check requests +func handleHealthCheck(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + func handleEvalCQL(w http.ResponseWriter, req *http.Request) { // 5MB limit for body size: bodyR := io.LimitReader(req.Body, 5e6) @@ -106,8 +296,21 @@ func handleEvalCQL(w http.ResponseWriter, req *http.Request) { sendError(w, err, http.StatusInternalServerError) return } - - elm, err := cql.Parse(req.Context(), []string{evalCQLReq.CQL, fhirHelpers}, cql.ParseConfig{DataModels: [][]byte{fhirDM}}) + + // Read all files from the uploads directory + uploadedLibraries, err := readUploadedLibraries() + if err != nil { + sendError(w, fmt.Errorf("failed to read uploaded libraries: %w", err), http.StatusInternalServerError) + return + } + + // Add uploaded libraries, FHIRHelpers, and the input CQL to the list of libraries to parse + libraries := append([]string{evalCQLReq.CQL, fhirHelpers}, uploadedLibraries...) + + // Log the libraries being used + log.Infof("Parsing %d libraries", len(libraries)) + + elm, err := cql.Parse(req.Context(), libraries, cql.ParseConfig{DataModels: [][]byte{fhirDM}}) if err != nil { sendError(w, fmt.Errorf("failed to parse: %w", err), http.StatusInternalServerError) return @@ -142,8 +345,8 @@ func handleEvalCQL(w http.ResponseWriter, req *http.Request) { func sendError(w http.ResponseWriter, err error, code int) { log.Errorf("%v", err) + w.WriteHeader(code) // Set status code first w.Write([]byte("Error: " + err.Error())) // be careful in the future, may not always want to send full error strings to the client - w.WriteHeader(code) } type evalCQLRequest struct { @@ -171,3 +374,46 @@ func getTerminologyProvider() (*terminology.LocalFHIRProvider, error) { } return tp, nil } + +// readUploadedLibraries reads all .cql files from the uploads directory +func readUploadedLibraries() ([]string, error) { + fileMutex.Lock() + defer fileMutex.Unlock() + + // Read all files in the uploads directory + files, err := os.ReadDir(uploadDir) + if err != nil { + if os.IsNotExist(err) { + // If the directory doesn't exist, return an empty slice + return []string{}, nil + } + return nil, fmt.Errorf("failed to read uploads directory: %w", err) + } + + var libraries []string + + // Read the content of each .cql file + for _, file := range files { + if file.IsDir() { + continue + } + + // Only process .cql files + if !strings.HasSuffix(strings.ToLower(file.Name()), ".cql") { + continue + } + + // Read the file content + filePath := filepath.Join(uploadDir, file.Name()) + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", file.Name(), err) + } + + // Add the file content to the list of libraries + libraries = append(libraries, string(content)) + log.Infof("Added library from file: %s", file.Name()) + } + + return libraries, nil +} diff --git a/cmd/cqlplay/static/cqlPlay.js b/cmd/cqlplay/static/cqlPlay.js index 802898a..ee65a38 100644 --- a/cmd/cqlplay/static/cqlPlay.js +++ b/cmd/cqlplay/static/cqlPlay.js @@ -81,6 +81,24 @@ function bindButtonActions() { .addEventListener('click', function(e) { showDataTab(); }); + document.getElementById('filesTabButton') + .addEventListener('click', function(e) { + showFilesTab(); + }); + + // File management buttons + document.getElementById('browseButton').addEventListener('click', function(e) { + document.getElementById('fileInput').click(); + }); + document.getElementById('fileInput').addEventListener('change', function(e) { + handleFileSelect(e); + }); + + // Set up drag and drop events + const dropZone = document.getElementById('dropZone'); + dropZone.addEventListener('dragover', handleDragOver); + dropZone.addEventListener('dragleave', handleDragLeave); + dropZone.addEventListener('drop', handleDrop); } /** @@ -94,6 +112,9 @@ function runCQL() { document.getElementById('results').innerHTML = xhr.responseText; Prism.highlightAll(); results = xhr.responseText; + + // Save state after running CQL + saveState(); } }; xhr.open('POST', '/eval_cql', true); @@ -102,25 +123,371 @@ function runCQL() { } /** - * showDataTab shows the data tab and hides the CQL tab. + * showDataTab shows the data tab and hides the other tabs. */ function showDataTab() { document.getElementById('cqlEntry').style.display = 'none'; document.getElementById('dataEntry').style.display = 'block'; + document.getElementById('filesEntry').style.display = 'none'; - document.getElementById('dataTabButton').className += 'active'; + document.getElementById('dataTabButton').className = 'active'; document.getElementById('cqlTabButton').className = ''; + document.getElementById('filesTabButton').className = ''; + + // Save state when tab is changed + saveState(); } /** - * showCQLTab shows the CQL tab and hides the data tab. + * showCQLTab shows the CQL tab and hides the other tabs. */ function showCQLTab() { document.getElementById('cqlEntry').style.display = 'block'; document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('filesEntry').style.display = 'none'; + + document.getElementById('cqlTabButton').className = 'active'; + document.getElementById('dataTabButton').className = ''; + document.getElementById('filesTabButton').className = ''; + + // Save state when tab is changed + saveState(); +} + +/** + * showFilesTab shows the Files tab and hides the other tabs. + */ +function showFilesTab() { + document.getElementById('cqlEntry').style.display = 'none'; + document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('filesEntry').style.display = 'block'; - document.getElementById('cqlTabButton').className += 'active'; + document.getElementById('filesTabButton').className = 'active'; + document.getElementById('cqlTabButton').className = ''; document.getElementById('dataTabButton').className = ''; + + // Refresh the file list when showing the tab + listFiles(); + + // Save state when tab is changed + saveState(); +} + +/** + * handleDragOver handles the dragover event for the drop zone. + */ +function handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.add('dragover'); +} + +/** + * handleDragLeave handles the dragleave event for the drop zone. + */ +function handleDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); +} + +/** + * handleDrop handles the drop event for the drop zone. + */ +function handleDrop(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + + const files = e.dataTransfer.files; + if (files.length > 0) { + uploadFiles(files); + } +} + +/** + * handleFileSelect handles the file selection from the file input. + */ +function handleFileSelect(e) { + const files = e.target.files; + if (files.length > 0) { + uploadFiles(files); + } +} + +/** + * uploadFiles uploads the selected files to the server. + */ +function uploadFiles(files) { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + // Only upload .cql files + if (!file.name.toLowerCase().endsWith('.cql')) { + alert('Only .cql files are supported'); + continue; + } + + // Create a FormData object + const formData = new FormData(); + formData.append('file', file); + + // Create a new XMLHttpRequest + const xhr = new XMLHttpRequest(); + + // Add the file to the UI with progress indicator + const fileId = 'file-' + Date.now() + '-' + i; + addFileToUI(fileId, file.name, 'Uploading...', true); + + // Set up the request + xhr.open('POST', '/upload_file', true); + + // Set up event handlers + xhr.onload = function() { + if (xhr.status === 200) { + // Update the file in the UI + updateFileInUI(fileId, file.name, JSON.parse(xhr.responseText).size, false); + } else { + // Remove the file from the UI + removeFileFromUI(fileId); + alert('Upload failed: ' + xhr.responseText); + } + }; + + xhr.onerror = function() { + // Remove the file from the UI + removeFileFromUI(fileId); + alert('Upload failed'); + }; + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + updateProgressInUI(fileId, percentComplete); + } + }; + + // Send the request + xhr.send(formData); + } +} + +/** + * addFileToUI adds a file to the UI. + */ +function addFileToUI(id, name, size, uploading) { + // Remove the "No files" message if it exists + const noFilesMessage = document.getElementById('noFilesMessage'); + if (noFilesMessage) { + noFilesMessage.remove(); + } + + // Create the file item + const fileItem = document.createElement('div'); + fileItem.id = id; + fileItem.className = 'fileItem'; + + // Create the file info + const fileInfo = document.createElement('div'); + fileInfo.className = 'fileInfo'; + + // Create the file name + const fileName = document.createElement('div'); + fileName.className = 'fileName'; + fileName.textContent = name; + fileInfo.appendChild(fileName); + + // Create the file size + const fileSize = document.createElement('div'); + fileSize.className = 'fileSize'; + fileSize.textContent = size; + fileInfo.appendChild(fileSize); + + // Add the file info to the file item + fileItem.appendChild(fileInfo); + + // If uploading, add a progress bar + if (uploading) { + const uploadProgress = document.createElement('div'); + uploadProgress.className = 'uploadProgress'; + + const uploadProgressBar = document.createElement('div'); + uploadProgressBar.className = 'uploadProgressBar'; + uploadProgressBar.style.width = '0%'; + + uploadProgress.appendChild(uploadProgressBar); + fileInfo.appendChild(uploadProgress); + } else { + // Create the file actions + const fileActions = document.createElement('div'); + fileActions.className = 'fileActions'; + + // Create the delete button + const deleteButton = document.createElement('button'); + deleteButton.className = 'deleteButton'; + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', function() { + deleteFile(name, id); + }); + + fileActions.appendChild(deleteButton); + fileItem.appendChild(fileActions); + } + + // Add the file item to the uploaded files container + document.getElementById('uploadedFiles').appendChild(fileItem); +} + +/** + * updateFileInUI updates a file in the UI. + */ +function updateFileInUI(id, name, size, uploading) { + const fileItem = document.getElementById(id); + if (!fileItem) return; + + // Update the file size + const fileSize = fileItem.querySelector('.fileSize'); + fileSize.textContent = size; + + // Remove the progress bar if it exists + const uploadProgress = fileItem.querySelector('.uploadProgress'); + if (uploadProgress) { + uploadProgress.remove(); + } + + // If not uploading, add the delete button + if (!uploading && !fileItem.querySelector('.fileActions')) { + // Create the file actions + const fileActions = document.createElement('div'); + fileActions.className = 'fileActions'; + + // Create the delete button + const deleteButton = document.createElement('button'); + deleteButton.className = 'deleteButton'; + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', function() { + deleteFile(name, id); + }); + + fileActions.appendChild(deleteButton); + fileItem.appendChild(fileActions); + } +} + +/** + * updateProgressInUI updates the progress bar in the UI. + */ +function updateProgressInUI(id, percent) { + const fileItem = document.getElementById(id); + if (!fileItem) return; + + const uploadProgressBar = fileItem.querySelector('.uploadProgressBar'); + if (uploadProgressBar) { + uploadProgressBar.style.width = percent + '%'; + } +} + +/** + * removeFileFromUI removes a file from the UI. + */ +function removeFileFromUI(id) { + const fileItem = document.getElementById(id); + if (fileItem) { + fileItem.remove(); + } + + // If there are no more files, add the "No files" message + const uploadedFiles = document.getElementById('uploadedFiles'); + if (uploadedFiles.children.length === 0) { + const noFilesMessage = document.createElement('p'); + noFilesMessage.id = 'noFilesMessage'; + noFilesMessage.textContent = 'No files uploaded yet'; + uploadedFiles.appendChild(noFilesMessage); + } +} + +/** + * deleteFile deletes a file from the server. + */ +function deleteFile(filename, id) { + if (!confirm('Are you sure you want to delete ' + filename + '?')) { + return; + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/delete_file', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + + xhr.onload = function() { + if (xhr.status === 200) { + // Remove the file from the UI + removeFileFromUI(id); + } else { + alert('Delete failed: ' + xhr.responseText); + } + }; + + xhr.onerror = function() { + alert('Delete failed'); + }; + + xhr.send(JSON.stringify({ filename: filename })); +} + +/** + * listFiles lists the files on the server. + */ +function listFiles() { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/list_files', true); + + xhr.onload = function() { + if (xhr.status === 200) { + // Clear the uploaded files container + const uploadedFiles = document.getElementById('uploadedFiles'); + uploadedFiles.innerHTML = ''; + + // Parse the response + const response = JSON.parse(xhr.responseText); + + // If there are no files, add the "No files" message + if (response.files.length === 0) { + const noFilesMessage = document.createElement('p'); + noFilesMessage.id = 'noFilesMessage'; + noFilesMessage.textContent = 'No files uploaded yet'; + uploadedFiles.appendChild(noFilesMessage); + return; + } + + // Add each file to the UI + for (let i = 0; i < response.files.length; i++) { + const file = response.files[i]; + const fileId = 'file-' + i; + addFileToUI(fileId, file.name, formatFileSize(file.size), false); + } + } else { + alert('Failed to list files: ' + xhr.responseText); + } + }; + + xhr.onerror = function() { + alert('Failed to list files'); + }; + + xhr.send(); +} + +/** + * formatFileSize formats a file size in bytes to a human-readable string. + */ +function formatFileSize(bytes) { + if (bytes < 1024) { + return bytes + ' bytes'; + } else if (bytes < 1024 * 1024) { + return (bytes / 1024).toFixed(2) + ' KB'; + } else { + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; + } } /** @@ -150,6 +517,91 @@ function setupPrism() { 'syntax-highlighted', codeInput.templates.prism(Prism, [])); } +/** + * saveState saves the current state to localStorage. + */ +function saveState() { + localStorage.setItem('cqlplay_code', code); + localStorage.setItem('cqlplay_data', data); + localStorage.setItem('cqlplay_results', results); + + // Save active tab + let activeTab = 'cql'; + if (document.getElementById('dataTabButton').className === 'active') { + activeTab = 'data'; + } else if (document.getElementById('filesTabButton').className === 'active') { + activeTab = 'files'; + } + localStorage.setItem('cqlplay_activeTab', activeTab); +} + +/** + * restoreState restores the state from localStorage. + */ +function restoreState() { + if (localStorage.getItem('cqlplay_code')) { + code = localStorage.getItem('cqlplay_code'); + document.getElementById('cqlInput').value = code; + } + + if (localStorage.getItem('cqlplay_data')) { + data = localStorage.getItem('cqlplay_data'); + document.getElementById('dataInput').value = data; + } + + if (localStorage.getItem('cqlplay_results')) { + results = localStorage.getItem('cqlplay_results'); + document.getElementById('results').innerHTML = results; + Prism.highlightAll(); + } + + // Restore active tab + if (localStorage.getItem('cqlplay_activeTab')) { + const activeTab = localStorage.getItem('cqlplay_activeTab'); + if (activeTab === 'data') { + showDataTab(); + } else if (activeTab === 'files') { + showFilesTab(); + } else { + showCQLTab(); + } + } +} + +/** + * startHealthCheck starts a health check interval to detect server restarts. + */ +function startHealthCheck() { + let serverWasDown = false; + + // Check server health every 2 seconds + setInterval(() => { + fetch('/health', { method: 'GET' }) + .then(response => { + if (response.ok && serverWasDown) { + // Server was down but is now up - refresh + console.log('Server restarted, refreshing page...'); + serverWasDown = false; + // Save state before refreshing + saveState(); + window.location.reload(); + } else if (response.ok) { + // Server is up and was not down before + serverWasDown = false; + } + }) + .catch(() => { + // Server is down + if (!serverWasDown) { + console.log('Server is down, waiting for it to come back up...'); + // Save state when server first goes down + saveState(); + } + serverWasDown = true; + }); + }, 2000); +} + /** * main is the entrypoint for the script. */ @@ -159,8 +611,26 @@ function main() { bindInputsOnChange(); bindButtonActions(); - // Initially hide dataEntry tab: + // Initially hide dataEntry and filesEntry tabs: document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('filesEntry').style.display = 'none'; + + // Restore state from localStorage + restoreState(); + + // Start health check for hot reload + startHealthCheck(); + + // Add input event listeners for state saving + document.getElementById('cqlInput').addEventListener('input', function(e) { + code = e.target.value; + saveState(); + }); + + document.getElementById('dataInput').addEventListener('input', function(e) { + data = e.target.value; + saveState(); + }); } -main(); // All code actually executed when the script is loaded by the HTML. \ No newline at end of file +main(); // All code actually executed when the script is loaded by the HTML. diff --git a/cmd/cqlplay/static/index.html b/cmd/cqlplay/static/index.html index ddd8a8a..c503b76 100644 --- a/cmd/cqlplay/static/index.html +++ b/cmd/cqlplay/static/index.html @@ -43,6 +43,7 @@
Drag and drop CQL files here
+or
+ + +No files uploaded yet
+