package web import ( "bytes" "encoding/csv" "fmt" "log" "net/http" "regexp" "strconv" "strings" "sync" "time" root "marmic/servicetrade-toolbox" "marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/middleware" "marmic/servicetrade-toolbox/internal/utils" "github.com/gorilla/mux" ) // RemovalResult represents the result of a single job removal type RemovalResult struct { JobID string FilesFound int FilesRemoved int Success bool ErrorMsg string Files []struct { Name string Success bool Error string } } // RemovalSession stores removal results for pagination type RemovalSession struct { Results []RemovalResult TotalJobs int SuccessCount int ErrorCount int TotalFiles int TotalTime time.Duration CreatedAt time.Time } // Global map to store removal sessions (in production, use Redis or database) var removalSessions = make(map[string]RemovalSession) // DocumentRemoveHandler handles the document removal page func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } tmpl := root.WebTemplates data := map[string]interface{}{ "Title": "Document Removal", "Session": session, "DocumentTypes": []map[string]string{ {"value": "1", "label": "Job Paperwork"}, {"value": "2", "label": "Job Vendor Bill"}, {"value": "7", "label": "Generic Attachment"}, {"value": "10", "label": "Blank Paperwork"}, {"value": "14", "label": "Job Invoice"}, }, } if r.Header.Get("HX-Request") == "true" { // For HTMX requests, just send the document_remove partial if err := tmpl.ExecuteTemplate(w, "document_remove", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } else { // For full page requests, first render document_remove into a buffer var contentBuf bytes.Buffer if err := tmpl.ExecuteTemplate(&contentBuf, "document_remove", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Add the rendered content to the data for the layout data["BodyContent"] = contentBuf.String() // Now render the layout with our content if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } } // ProcessRemoveCSVHandler processes a CSV file containing job IDs for document removal func ProcessRemoveCSVHandler(w http.ResponseWriter, r *http.Request) { // We don't use the session in the body but check it for auth _, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Check if the request method is POST if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse the multipart form data with a 10MB limit if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest) return } // Get the file from the form file, _, err := r.FormFile("csv") if err != nil { http.Error(w, "Error retrieving file: "+err.Error(), http.StatusBadRequest) return } defer file.Close() // Read the CSV data csvData, err := csv.NewReader(file).ReadAll() if err != nil { http.Error(w, "Error reading CSV file: "+err.Error(), http.StatusBadRequest) return } // Extract job IDs from CSV - first column only for simplicity var jobIDs []string for rowIndex, row := range csvData { // Skip header row if present if rowIndex == 0 && len(csvData) > 1 { // Check if first row looks like a header (non-numeric content) _, err := strconv.Atoi(strings.TrimSpace(row[0])) if err != nil { continue // Skip this row as it's likely a header } } if len(row) > 0 && row[0] != "" { jobID := strings.TrimSpace(row[0]) if jobID != "" { jobIDs = append(jobIDs, jobID) } } } totalJobs := len(jobIDs) log.Printf("Extracted %d job IDs from CSV", totalJobs) if totalJobs == 0 { http.Error(w, "No valid job IDs found in the CSV file", http.StatusBadRequest) return } // Create a hidden input with the job IDs jobsValue := strings.Join(jobIDs, ",") // Generate HTML for the main response (hidden input for job-ids-removal-container) var responseHTML bytes.Buffer responseHTML.WriteString(fmt.Sprintf(``, jobsValue)) responseHTML.WriteString(fmt.Sprintf(`

Found %d job(s) in the CSV file

`, totalJobs)) // Generate out-of-band swap for the preview section - simplified version responseHTML.WriteString(fmt.Sprintf(`

✓ Jobs Detected

Remove from %d job(s)

`, totalJobs)) // Send the response with the hidden input and preview w.Header().Set("Content-Type", "text/html") w.Write(responseHTML.Bytes()) } // After the CSV is processed, a separate request should load job attachments // This handler is for Step 2 func JobSelectionHandler(w http.ResponseWriter, r *http.Request) { // We don't use the session directly but check it for auth _, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } jobIDs := r.FormValue("jobIDs") if jobIDs == "" { http.Error(w, "No job IDs provided", http.StatusBadRequest) return } jobs := strings.Split(jobIDs, ",") if len(jobs) == 0 { http.Error(w, "No valid job IDs found", http.StatusBadRequest) return } var resultHTML bytes.Buffer resultHTML.WriteString("
") resultHTML.WriteString("

Jobs from CSV

") resultHTML.WriteString("

Click a job to view and manage its documents.

") resultHTML.WriteString("
") for _, jobID := range jobs { resultHTML.WriteString(fmt.Sprintf(` `, jobID, jobID, jobID, jobID, jobID)) } resultHTML.WriteString("
") // End of job-list resultHTML.WriteString("
") // End of job-list-container w.Header().Set("Content-Type", "text/html") w.Write(resultHTML.Bytes()) } // GetJobAttachmentsHandler retrieves attachments for a specific job func GetJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { vars := mux.Vars(r) jobID := vars["jobID"] renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID) return } vars := mux.Vars(r) jobID := vars["jobID"] if jobID == "" { renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID) return } // Get attachments for the job attachments, err := session.GetJobAttachments(jobID) if err != nil { renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Failed to get attachments: %v", err), jobID) return } tmpl := root.WebTemplates data := map[string]interface{}{ "JobID": jobID, "Attachments": attachments, "Session": session, } if err := tmpl.ExecuteTemplate(w, "job_attachments", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } // RemoveJobAttachmentsHandler handles the removal of attachments from a job func RemoveJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { vars := mux.Vars(r) jobID := vars["jobID"] renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID) return } vars := mux.Vars(r) jobID := vars["jobID"] if jobID == "" { renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID) return } // Parse the form if err := r.ParseForm(); err != nil { renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Error parsing form: %v", err), jobID) return } // Get the attachment IDs to remove attachmentIDs := r.PostForm["attachment_ids"] if len(attachmentIDs) == 0 { renderErrorTemplate(w, "job_attachments", "No attachments selected for deletion", jobID) return } // Process deletion with rate limiting (max 5 concurrent requests) results := struct { Success bool SuccessCount int ErrorCount int Files []struct { Name string Success bool Error string } }{ Success: true, Files: make([]struct { Name string Success bool Error string }, 0, len(attachmentIDs)), } // Set up rate limiting semaphore := make(chan struct{}, 5) // Allow 5 concurrent requests var wg sync.WaitGroup var mu sync.Mutex // Mutex for updating results for _, attachmentID := range attachmentIDs { wg.Add(1) semaphore <- struct{}{} // Acquire semaphore go func(id string) { defer wg.Done() defer func() { <-semaphore }() // Release semaphore // Get attachment info first to get the name attachmentInfo, err := session.GetAttachmentInfo(id) fileResult := struct { Name string Success bool Error string }{ Name: fmt.Sprintf("Attachment ID: %s", id), } if err == nil { // Get description if available if description, ok := attachmentInfo["description"].(string); ok { fileResult.Name = description } } // For all attachment types, we'll use the attachment endpoint for deletion // The API endpoint is /attachment/{id} as defined in DeleteAttachment method log.Printf("Deleting attachment %s (ID: %s) using attachment endpoint", fileResult.Name, id) deleteErr := session.DeleteAttachment(id) mu.Lock() defer mu.Unlock() if deleteErr != nil { fileResult.Success = false fileResult.Error = deleteErr.Error() results.ErrorCount++ results.Success = false } else { fileResult.Success = true results.SuccessCount++ } results.Files = append(results.Files, fileResult) // Add a slight delay to avoid overwhelming the API time.Sleep(100 * time.Millisecond) }(attachmentID) } wg.Wait() // Wait for all deletions to complete tmpl := root.WebTemplates data := map[string]interface{}{ "JobID": jobID, "Session": session, "SuccessCount": results.SuccessCount, "ErrorCount": results.ErrorCount, "JobsProcessed": 1, "Results": []map[string]interface{}{ { "JobID": jobID, "Success": results.Success, "Files": results.Files, }, }, } // Generate HTML for results that will go to the removal_results div if err := tmpl.ExecuteTemplate(w, "removal_results", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } // JobListHandler renders the job list for document removal func JobListHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { renderErrorTemplate(w, "job_list", "You must be logged in to use this feature") return } if err := r.ParseForm(); err != nil { renderErrorTemplate(w, "job_list", fmt.Sprintf("Error parsing form: %v", err)) return } tmpl := root.WebTemplates data := map[string]interface{}{ "JobIDs": r.PostForm["job_ids"], "Session": session, } if err := tmpl.ExecuteTemplate(w, "job_list", data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } // Helper function to render error templates func renderErrorTemplate(w http.ResponseWriter, templateName, errorMsg string, jobID ...string) { tmpl := root.WebTemplates data := map[string]interface{}{ "Error": errorMsg, } if len(jobID) > 0 && jobID[0] != "" { data["JobID"] = jobID[0] } if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { log.Printf("Template execution error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } // BulkRemoveDocumentsHandler handles bulk removal of documents from multiple jobs func BulkRemoveDocumentsHandler(w http.ResponseWriter, r *http.Request) { startTime := time.Now() if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse the form if err := r.ParseForm(); err != nil { http.Error(w, fmt.Sprintf("Error parsing form: %v", err), http.StatusBadRequest) return } // Get job IDs from the form jobIDs := r.FormValue("jobIDs") if jobIDs == "" { http.Error(w, "No job IDs provided", http.StatusBadRequest) return } jobs := strings.Split(jobIDs, ",") if len(jobs) == 0 { http.Error(w, "No valid job IDs provided", http.StatusBadRequest) return } // Get document types to remove (optional) var docTypes []string if types := r.Form["documentTypes"]; len(types) > 0 { docTypes = types log.Printf("Filtering by document types: %v", docTypes) } // Get filename patterns to match (optional) var filenamePatterns []string if patterns := r.FormValue("filenamePatterns"); patterns != "" { filenamePatterns = strings.Split(patterns, ",") for i, p := range filenamePatterns { filenamePatterns[i] = strings.TrimSpace(p) } log.Printf("Filtering by filename patterns: %v", filenamePatterns) } // Get age filter (optional) var ageFilterDays int if ageStr := r.FormValue("age-filter"); ageStr != "" { if days, err := strconv.Atoi(ageStr); err == nil && days > 0 { ageFilterDays = days log.Printf("Using age filter: older than %d days", ageFilterDays) } } // Calculate cutoff date if using age filter var cutoffDate time.Time if ageFilterDays > 0 { cutoffDate = time.Now().AddDate(0, 0, -ageFilterDays) log.Printf("Cutoff date for age filter: %s", cutoffDate.Format("2006-01-02")) } // Structure to track results type BulkRemovalResult struct { JobsProcessed int JobsWithErrors int TotalFiles int SuccessCount int ErrorCount int Duration time.Duration JobResults []struct { JobID string FilesFound int FilesRemoved int Success bool ErrorMsg string Files []struct { Name string Success bool Error string } } } results := BulkRemovalResult{} var wg sync.WaitGroup var mu sync.Mutex semaphore := make(chan struct{}, 5) // Increased back to 5 since we've optimized the overhead // Process each job for _, jobID := range jobs { wg.Add(1) go func(jobID string) { defer wg.Done() log.Printf("Processing job ID: %s for document removal", jobID) jobResult := struct { JobID string FilesFound int FilesRemoved int Success bool ErrorMsg string Files []struct { Name string Success bool Error string } }{ JobID: jobID, Success: true, Files: []struct { Name string Success bool Error string }{}, } // Check job permissions first hasAccess, reason, err := session.CheckJobPermissions(jobID) if err != nil { log.Printf("Error checking permissions for job %s: %v", jobID, err) } else if !hasAccess { log.Printf("No access to job %s: %s", jobID, reason) mu.Lock() jobResult.Success = false jobResult.ErrorMsg = fmt.Sprintf("Cannot access job: %s", reason) results.JobResults = append(results.JobResults, jobResult) results.JobsWithErrors++ mu.Unlock() return } // Get attachments using the most reliable method first var attachments []map[string]interface{} // Try the specialized GetJobAttachments method first attachments, err = session.GetJobAttachments(jobID) if err != nil { log.Printf("Error getting attachments for job %s: %v", jobID, err) attachments = []map[string]interface{}{} // Ensure it's initialized } // If no attachments found, try the paperwork endpoint as fallback if len(attachments) == 0 { log.Printf("No attachments found via GetJobAttachments for job %s, trying paperwork endpoint", jobID) paperworkItems, err := session.GetJobPaperwork(jobID) if err != nil { log.Printf("Error getting paperwork for job %s: %v", jobID, err) } else { attachments = paperworkItems } } log.Printf("Found %d attachments for job %s", len(attachments), jobID) // Filter attachments based on criteria var attachmentsToDelete []map[string]interface{} for _, attachment := range attachments { // Get the attachment ID attachmentIDRaw, idOk := attachment["id"].(float64) if !idOk { continue } attachmentIDStr := fmt.Sprintf("%.0f", attachmentIDRaw) // Get filename from any available field var filename string if desc, ok := attachment["description"].(string); ok && desc != "" { filename = desc } else if name, ok := attachment["name"].(string); ok && name != "" { filename = name } else if fname, ok := attachment["fileName"].(string); ok && fname != "" { filename = fname } else { filename = fmt.Sprintf("Attachment ID: %s", attachmentIDStr) } // Check document type filter if len(docTypes) > 0 { typeMatches := false if purposeId, ok := attachment["purposeId"].(float64); ok { for _, docType := range docTypes { docTypeClean := strings.TrimLeft(docType, "0") if docTypeInt, err := strconv.Atoi(docTypeClean); err == nil { if float64(docTypeInt) == purposeId { typeMatches = true break } } } } if !typeMatches { continue } } // Check filename pattern if len(filenamePatterns) > 0 && !matchesAnyPattern(filename, filenamePatterns) { continue } // Check age filter if applicable if ageFilterDays > 0 { var createdAt time.Time var hasDate bool // Try to get the creation date if created, ok := attachment["createdOn"].(string); ok { if parsedTime, err := time.Parse(time.RFC3339, created); err == nil { createdAt = parsedTime hasDate = true } } else if created, ok := attachment["created"].(string); ok { if parsedTime, err := time.Parse(time.RFC3339, created); err == nil { createdAt = parsedTime hasDate = true } } else if createdVal, ok := attachment["created"].(float64); ok { createdAt = time.Unix(int64(createdVal), 0) hasDate = true } if hasDate && createdAt.After(cutoffDate) { continue // Skip if not old enough } } // This attachment matches all criteria attachmentsToDelete = append(attachmentsToDelete, attachment) } jobResult.FilesFound = len(attachmentsToDelete) // Process deletions with rate limiting var deletionWg sync.WaitGroup for _, attachment := range attachmentsToDelete { deletionWg.Add(1) attachmentCopy := attachment go func(att map[string]interface{}) { defer deletionWg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() attachmentIDFloat := att["id"].(float64) attachmentIDStr := fmt.Sprintf("%.0f", attachmentIDFloat) var filename string if desc, ok := att["description"].(string); ok && desc != "" { filename = desc } else if name, ok := att["name"].(string); ok && name != "" { filename = name } else if fname, ok := att["fileName"].(string); ok && fname != "" { filename = fname } else { filename = fmt.Sprintf("Attachment ID: %s", attachmentIDStr) } fileResult := struct { Name string Success bool Error string }{ Name: filename, } deleteErr := session.DeleteAttachment(attachmentIDStr) mu.Lock() defer mu.Unlock() if deleteErr != nil { fileResult.Success = false fileResult.Error = deleteErr.Error() jobResult.Success = false } else { fileResult.Success = true jobResult.FilesRemoved++ } jobResult.Files = append(jobResult.Files, fileResult) // Reduced delay time.Sleep(200 * time.Millisecond) }(attachmentCopy) } deletionWg.Wait() mu.Lock() results.JobsProcessed++ if jobResult.Success { results.SuccessCount += jobResult.FilesRemoved } else { results.ErrorCount += (jobResult.FilesFound - jobResult.FilesRemoved) } results.TotalFiles += jobResult.FilesFound results.JobResults = append(results.JobResults, jobResult) mu.Unlock() }(jobID) } // Wait for all jobs to complete wg.Wait() // Calculate total duration totalDuration := time.Since(startTime) results.Duration = totalDuration log.Printf("Document removal completed in %v", totalDuration) // Convert results to RemovalResult format for session storage var removalResults []RemovalResult for _, jobResult := range results.JobResults { removalResult := RemovalResult{ JobID: jobResult.JobID, FilesFound: jobResult.FilesFound, FilesRemoved: jobResult.FilesRemoved, Success: jobResult.Success, ErrorMsg: jobResult.ErrorMsg, Files: jobResult.Files, } removalResults = append(removalResults, removalResult) } // Store results in session for pagination sessionID := fmt.Sprintf("removal_%d", time.Now().UnixNano()) removalSession := RemovalSession{ Results: removalResults, TotalJobs: results.JobsProcessed, SuccessCount: results.SuccessCount, ErrorCount: results.ErrorCount, TotalFiles: results.TotalFiles, TotalTime: totalDuration, CreatedAt: time.Now(), } // Store in global map (in production, use Redis or database) removalSessions[sessionID] = removalSession // Get configurable page size from form, with fallback to default limitStr := r.FormValue("limit") limit := utils.DefaultPageSize if limitStr != "" { if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { if parsedLimit > utils.MaxPageSize { limit = utils.MaxPageSize } else { limit = parsedLimit } } } // Return first page of results with configurable page size renderRemovalResultsPage(w, sessionID, utils.DefaultPage, limit) } // RemovalResultsHandler handles pagination for removal results func RemovalResultsHandler(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Query().Get("session_id") if sessionID == "" { http.Error(w, "Session ID required", http.StatusBadRequest) return } page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = utils.DefaultPage } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit < 1 { limit = utils.DefaultPageSize } renderRemovalResultsPage(w, sessionID, page, limit) } // renderRemovalResultsPage renders a paginated page of removal results func renderRemovalResultsPage(w http.ResponseWriter, sessionID string, page, limit int) { removalSession, exists := removalSessions[sessionID] if !exists { http.Error(w, "Removal session not found", http.StatusNotFound) return } totalResults := len(removalSession.Results) pagination := utils.CalculatePagination(totalResults, page, limit) // Get results for this page startIndex := (pagination.CurrentPage - 1) * pagination.Limit endIndex := startIndex + pagination.Limit if endIndex > totalResults { endIndex = totalResults } pageResults := utils.GetPageResults(removalSession.Results, startIndex, endIndex) // Add pagination info to each job result for the template var resultsWithPagination []map[string]interface{} for _, jobResult := range pageResults { resultMap := map[string]interface{}{ "JobID": jobResult.JobID, "FilesFound": jobResult.FilesFound, "FilesRemoved": jobResult.FilesRemoved, "Success": jobResult.Success, "ErrorMsg": jobResult.ErrorMsg, "Files": jobResult.Files, "FilePage": 1, // Default to first file "TotalFiles": len(jobResult.Files), "SessionID": sessionID, } resultsWithPagination = append(resultsWithPagination, resultMap) } data := map[string]interface{}{ "Results": resultsWithPagination, "TotalJobs": removalSession.TotalJobs, "SuccessCount": removalSession.SuccessCount, "ErrorCount": removalSession.ErrorCount, "TotalFiles": removalSession.TotalFiles, "TotalTime": removalSession.TotalTime, "TotalResults": pagination.TotalResults, "TotalPages": pagination.TotalPages, "CurrentPage": pagination.CurrentPage, "Limit": pagination.Limit, "StartIndex": pagination.StartIndex, "EndIndex": pagination.EndIndex, "StartPage": pagination.StartPage, "EndPage": pagination.EndPage, "SessionID": sessionID, } tmpl := root.WebTemplates if err := tmpl.ExecuteTemplate(w, "removal_results", data); err != nil { log.Printf("Template execution error: %v", err) // Don't call http.Error here as the response may have already started // Just log the error and return return } } // Helper function to check if a string matches any pattern in a slice func matchesAnyPattern(s string, patterns []string) bool { // Convert the string to lowercase for case-insensitive comparison sLower := strings.ToLower(s) for _, pattern := range patterns { // Check if the pattern is a wildcard pattern (contains *) if strings.Contains(pattern, "*") { // Convert wildcard pattern to regex regexPattern := strings.ReplaceAll(pattern, "*", ".*") match, _ := regexp.MatchString("(?i)^"+regexPattern+"$", s) if match { return true } } else { // For non-wildcard patterns, check for exact match (case-insensitive) if sLower == strings.ToLower(pattern) { return true } } } return false } // New handler: Serve a single job card with only one file (per-job file pagination) func RemovalJobFileHandler(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Query().Get("session_id") jobID := r.URL.Query().Get("job_id") filePageStr := r.URL.Query().Get("file_page") filePage := 1 if filePageStr != "" { if parsed, err := strconv.Atoi(filePageStr); err == nil && parsed > 0 { filePage = parsed } } removalSession, exists := removalSessions[sessionID] if !exists { http.Error(w, "Removal session not found", http.StatusNotFound) return } // Find the job result var jobResult *RemovalResult for i := range removalSession.Results { if removalSession.Results[i].JobID == jobID { jobResult = &removalSession.Results[i] break } } if jobResult == nil { http.Error(w, "Job not found in session", http.StatusNotFound) return } totalFiles := len(jobResult.Files) if totalFiles == 0 { // No files to show data := map[string]interface{}{ "JobID": jobResult.JobID, "FilesFound": jobResult.FilesFound, "FilesRemoved": jobResult.FilesRemoved, "Success": jobResult.Success, "ErrorMsg": jobResult.ErrorMsg, "Files": nil, "FilePage": 1, "TotalFiles": 0, "SessionID": sessionID, } tmpl := root.WebTemplates if err := tmpl.ExecuteTemplate(w, "removal_result_card", data); err != nil { log.Printf("Template execution error: %v", err) return } return } // Ensure filePage is within bounds if filePage > totalFiles { filePage = totalFiles } if filePage < 1 { filePage = 1 } // Create a copy of the job result with only the requested file jobResultCopy := RemovalResult{ JobID: jobResult.JobID, FilesFound: jobResult.FilesFound, FilesRemoved: jobResult.FilesRemoved, Success: jobResult.Success, ErrorMsg: jobResult.ErrorMsg, Files: []struct { Name string Success bool Error string }{jobResult.Files[filePage-1]}, } // Add pagination info for the template data := map[string]interface{}{ "JobID": jobResultCopy.JobID, "FilesFound": jobResultCopy.FilesFound, "FilesRemoved": jobResultCopy.FilesRemoved, "Success": jobResultCopy.Success, "ErrorMsg": jobResultCopy.ErrorMsg, "Files": jobResultCopy.Files, "FilePage": filePage, "TotalFiles": totalFiles, "SessionID": sessionID, } tmpl := root.WebTemplates if err := tmpl.ExecuteTemplate(w, "removal_result_card", data); err != nil { log.Printf("Template execution error: %v", err) return } }