Spaces:
Running
Running
| {% extends "base.html" %} | |
| {% block title %}Enter Question Details (V2){% endblock %} | |
| {% block head %} | |
| <style> | |
| .keyboard-hint { font-size: 0.8em; color: #6c757d; margin-top: 2px; } | |
| .status-buttons { display: flex; gap: 0.25rem; margin-top: 0.25rem; } | |
| .status-btn { flex: 1; font-size: 0.75rem; padding: 0.25rem 0.5rem; border: 1px solid #495057; background: transparent; color: #fff; border-radius: 0.25rem; cursor: pointer; transition: all 0.2s; } | |
| .status-btn.active { background: #0d6efd; border-color: #0d6efd; } | |
| .status-btn:hover { background: #495057; } | |
| .auto-extract-btn { min-width: 120px; } | |
| /* Tom Select Dark Theme */ | |
| .ts-wrapper .ts-control, .ts-wrapper .ts-control input { | |
| background: #212529; | |
| color: #fff; | |
| border-color: #6c757d; | |
| } | |
| .ts-dropdown { | |
| background: #212529; | |
| color: #fff; | |
| border-color: #6c757d; | |
| } | |
| .ts-dropdown .option { | |
| color: #fff; | |
| } | |
| .ts-dropdown .option:hover, .ts-dropdown .active { | |
| background: #495057; | |
| } | |
| </style> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="container mt-3 mb-5"> | |
| <div class="card bg-dark text-white"> | |
| <div class="card-header d-flex justify-content-between align-items-center"> | |
| <div> | |
| <h1 class="h3 mb-0">Step 3: Enter Question Details</h1> | |
| <div class="keyboard-hint">Shortcuts: Shift+Q for Next | Shift+C (Correct) | Shift+W (Wrong) | Shift+U (Unattempted)</div> | |
| {% if classified_count is defined and total_questions is defined %} | |
| <div class="d-flex align-items-center mt-1"> | |
| <span class="badge bg-info"> | |
| <i class="bi bi-tags me-1"></i>Classification: | |
| <strong>{{ classified_count }}</strong>/{{ total_questions }} questions | |
| </span> | |
| </div> | |
| {% endif %} | |
| </div> | |
| <div> | |
| <a href="/cropv2/{{ session_id }}/0" class="btn btn-secondary"><i class="bi bi-arrow-left me-1"></i> Go Back to Cropping</a> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <fieldset class="mb-4"> | |
| <legend class="h5">Upload Answer Key (Optional)</legend> | |
| <div class="mb-3"> | |
| <label for="json-upload" class="form-label">Upload a JSON file to auto-fill answers.</label> | |
| <input class="form-control" type="file" id="json-upload" accept=".json"> | |
| </div> | |
| </fieldset> | |
| <form id="questions-form"> | |
| {% if not nvidia_nim_available %} | |
| <div class="alert alert-warning alert-dismissible fade show" role="alert"> | |
| <i class="bi bi-exclamation-triangle me-2"></i> | |
| NVIDIA NIM OCR feature is not available. To enable automatic question number extraction, please set the <code>NVIDIA_API_KEY</code> environment variable. | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| {% else %} | |
| <div class="mb-3"> | |
| <button id="auto-extract-all" class="btn btn-primary"> | |
| <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span> | |
| <span class="extract-text">Auto Extract All Question Numbers</span> | |
| </button> | |
| <button id="extract-classify-all" class="btn btn-info"> | |
| <span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span> | |
| <span class="extract-text">Extract & Classify All</span> | |
| </button> | |
| </div> | |
| {% endif %} | |
| {% for image in images %} | |
| <fieldset class="row mb-4 border-bottom pb-3" data-question-index="{{ loop.index0 }}" id="question-fieldset-{{ image.id }}"> | |
| <input type="hidden" name="image_id_{{ loop.index0 }}" value="{{ image.id }}"> | |
| <legend class="h5 col-12 mb-3">Question {{ loop.index }} | |
| <button type="button" class="btn btn-sm btn-outline-danger float-end delete-question-btn" data-image-id="{{ image.id }}" data-index="{{ loop.index0 }}"> | |
| <i class="bi bi-trash"></i> Delete | |
| </button> | |
| </legend> | |
| <div class="col-md-3 mb-3"> | |
| <img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded" alt="Cropped Question {{ loop.index }}"> | |
| </div> | |
| <div class="col-md-9"> | |
| <div class="row"> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="question_number_{{ loop.index0 }}">Question Number *</label> | |
| {% if nvidia_nim_available %} | |
| <div class="input-group"> | |
| <input type="number" class="form-control" id="question_number_{{ loop.index0 }}" name="question_number_{{ loop.index0 }}" data-image-id="{{ image.id }}" value="{{ image.question_number or '' }}" required> | |
| <button class="btn btn-outline-secondary auto-extract-btn" type="button" data-image-id="{{ image.id }}" data-index="{{ loop.index0 }}"> | |
| <span class="extract-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span> | |
| <span class="extract-text">Auto Extract</span> | |
| </button> | |
| </div> | |
| {% else %} | |
| <input type="number" class="form-control" id="question_number_{{ loop.index0 }}" name="question_number_{{ loop.index0 }}" data-image-id="{{ image.id }}" value="{{ image.question_number or '' }}" required> | |
| {% endif %} | |
| </div> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="marked_solution_{{ loop.index0 }}">Your Answer</label> | |
| <input type="text" class="form-control" id="marked_solution_{{ loop.index0 }}" name="marked_solution_{{ loop.index0 }}" value="{{ image.marked_solution or '' }}"> | |
| </div> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="actual_solution_{{ loop.index0 }}">Correct Answer</label> | |
| <input type="text" class="form-control" id="actual_solution_{{ loop.index0 }}" name="actual_solution_{{ loop.index0 }}" value="{{ image.actual_solution or '' }}"> | |
| </div> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label">Status</label> | |
| <select class="form-select d-none" id="status_{{ loop.index0 }}" name="status_{{ loop.index0 }}"> | |
| <option value="correct" {% if image.status and image.status.lower() == 'correct' %}selected{% endif %}>Correct</option> | |
| <option value="wrong" {% if image.status and image.status.lower() == 'wrong' %}selected{% endif %}>Wrong</option> | |
| <option value="unattempted" {% if image.status and image.status.lower() == 'unattempted' or not image.status %}selected{% endif %}>Unattempted</option> | |
| </select> | |
| <div class="status-buttons" data-index="{{ loop.index0 }}"> | |
| <button type="button" class="status-btn {% if image.status and image.status.lower() == 'correct' %}active{% endif %}" data-status="correct">✓ Correct</button> | |
| <button type="button" class="status-btn {% if image.status and image.status.lower() == 'wrong' %}active{% endif %}" data-status="wrong">✗ Wrong</button> | |
| <button type="button" class="status-btn {% if image.status and image.status.lower() == 'unattempted' or not image.status %}active{% endif %}" data-status="unattempted">? Unattempted</button> | |
| </div> | |
| </div> | |
| <div class="row mt-2"> | |
| <div class="col-md-6"> | |
| <p class="mb-0"><strong>Subject:</strong> <span id="subject_{{ image.id }}">{{ image.subject or 'N/A' }}</span></p> | |
| </div> | |
| <div class="col-md-6"> | |
| <p class="mb-0"><strong>Chapter:</strong> <span id="chapter_{{ image.id }}">{{ image.chapter or 'N/A' }}</span></p> | |
| </div> | |
| </div> | |
| </fieldset> | |
| {% endfor %} | |
| <fieldset> | |
| <legend class="h4">Generate PDF</legend> | |
| <div class="row"> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="pdf_subject">PDF Subject *</label> | |
| <input type="text" id="pdf_subject" class="form-control" value="{{ session_data.subject or session_data.original_filename or '' }}" required> | |
| </div> | |
| <div class="col-md-6 mb-3" id="pdf-name-div"> | |
| <label class="form-label" for="pdf_name">PDF Name</label> | |
| <input type="text" id="pdf_name" class="form-control" value="{{ session_data.original_filename or '' }}"> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="pdf_tags">Tags (comma-separated)</label> | |
| <input type="text" id="pdf_tags" class="form-control" value="{{ session_data.tags or '' }}"> | |
| </div> | |
| <div class="col-md-6 mb-3"> | |
| <label class="form-label" for="pdf_notes">Notes</label> | |
| <textarea id="pdf_notes" class="form-control" rows="1">{{ session_data.notes or '' }}</textarea> | |
| </div> | |
| </div> | |
| <hr> | |
| <legend class="h5">Layout Options</legend> | |
| <div class="row align-items-end"> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="practice_mode">Practice Mode</label> | |
| <select id="practice_mode" class="form-select"> | |
| <option value="none" selected>None</option> | |
| <option value="portrait_2">Portrait 2</option> | |
| <option value="portrait_3">Portrait 3</option> | |
| <option value="landscape_2">Landscape 2</option> | |
| <option value="portrait_2_spacious">Portrait 2 - Spacious</option> | |
| </select> | |
| </div> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="orientation">Orientation</label> | |
| <select id="orientation" class="form-select"> | |
| <option value="portrait" selected>Portrait</option> | |
| <option value="landscape">Landscape</option> | |
| </select> | |
| </div> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="filter_type">Filter Questions</label> | |
| <select id="filter_type" class="form-select"> | |
| <option value="all">All</option> | |
| <option value="wrong">Wrong Only</option> | |
| <option value="unattempted">Unattempted Only</option> | |
| </select> | |
| </div> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="font_size_scale">Font Size Scale: <span id="font_size_val">1.0</span>x</label> | |
| <input type="range" class="form-range" id="font_size_scale" min="0.5" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('font_size_val').innerText = this.value"> | |
| </div> | |
| </div> | |
| <div id="standard-layout-options"> | |
| <div class="row"> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="images_per_page">Images per Page</label> | |
| <input type="number" id="images_per_page" class="form-control" value="4" min="1" max="20"> | |
| </div> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="grid_rows">Grid Rows</label> | |
| <input type="number" id="grid_rows" class="form-control" value="4" min="1"> | |
| </div> | |
| <div class="col-md-3 mb-3"> | |
| <label class="form-label" for="grid_cols">Grid Cols</label> | |
| <input type="number" id="grid_cols" class="form-control" value="1" min="1"> | |
| </div> | |
| </div> | |
| </div> | |
| </fieldset> | |
| <div class="d-flex gap-2 mt-3"> | |
| <button type="button" id="preview-btn" class="btn btn-secondary w-100">See Preview</button> | |
| <button type="submit" class="btn btn-primary w-100">Generate PDF</button> | |
| </div> | |
| </form> | |
| <!-- Preview Modal --> | |
| <div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true"> | |
| <div class="modal-dialog modal-xl"> | |
| <div class="modal-content"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title" id="previewModalLabel">PDF Preview</h5> | |
| <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> | |
| </div> | |
| <div class="modal-body text-center"> | |
| <img id="preview-img" src="" class="img-fluid" alt="PDF Preview"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="accordion mt-4" id="misc-accordion"> | |
| <div class="accordion-item bg-dark"> | |
| <h2 class="accordion-header" id="headingOne"> | |
| <button class="accordion-button bg-secondary text-white collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseMisc" aria-expanded="false" aria-controls="collapseMisc"> | |
| Add Miscellaneous Questions (Temporary) | |
| </button> | |
| </h2> | |
| <div id="collapseMisc" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#misc-accordion"> | |
| <div class="accordion-body"> | |
| <form id="misc-question-form"> | |
| <div class="row"> | |
| <div class="col-md-6 mb-3"><label class="form-label">Question Number</label><input type="text" id="misc_question_number" class="form-control"></div> | |
| <div class="col-md-6 mb-3"><label class="form-label">Subject</label><input type="text" id="misc_subject" class="form-control"></div> | |
| <div class="col-md-6 mb-3"><label class="form-label">Your Answer</label><input type="text" id="misc_marked_solution" class="form-control"></div> | |
| <div class="col-md-6 mb-3"><label class="form-label">Correct Answer</label><input type="text" id="misc_actual_solution" class="form-control"></div> | |
| <div class="col-md-6 mb-3"><label class="form-label">Status</label><select id="misc_status" class="form-select"><option value="correct">Correct</option><option value="wrong">Wrong</option><option value="unattempted" selected>Unattempted</option></select></div> | |
| <div class="col-md-6 mb-3"><label class="form-label">Image (Optional)</label><input type="file" id="misc_image" class="form-control" accept="image/*"></div> | |
| </div> | |
| <button type="button" id="add-misc-question" class="btn btn-info">Add Miscellaneous Question</button> | |
| </form> | |
| <hr class="my-4"> | |
| <h4 class="h5">Added Miscellaneous Questions:</h4> | |
| <div id="misc-questions-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="status" class="mt-3"></div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block scripts %} | |
| <script> | |
| const numImages = {{ images|length }}; | |
| const sessionId = '{{ session_id }}'; | |
| let answerKeyData = new Map(); | |
| let miscellaneousQuestions = []; | |
| async function initializeTomSelect() { | |
| try { | |
| const response = await fetch('/get_metadata_suggestions'); | |
| const suggestions = await response.json(); | |
| const subjectOptions = suggestions.subjects.map(s => ({value: s, text: s})); | |
| const tagOptions = suggestions.tags.map(t => ({value: t, text: t})); | |
| new TomSelect('#pdf_subject',{ | |
| create: true, | |
| persist: false, | |
| options: subjectOptions | |
| }); | |
| new TomSelect('#pdf_tags',{ | |
| persist: false, | |
| createOnBlur: true, | |
| create: true, | |
| plugins: ['remove_button'], | |
| options: tagOptions | |
| }); | |
| } catch (err) { | |
| console.error('Error initializing Tom Select:', err); | |
| } | |
| } | |
| function saveSettings() { | |
| // Session-specific settings | |
| const sessionSettings = { | |
| pdf_subject: document.getElementById('pdf_subject').value, | |
| pdf_tags: document.getElementById('pdf_tags').value, | |
| pdf_notes: document.getElementById('pdf_notes').value, | |
| pdf_name: document.getElementById('pdf_name').value, | |
| }; | |
| localStorage.setItem('pdfGeneratorSettings-{{ session_id }}', JSON.stringify(sessionSettings)); | |
| // Global layout settings | |
| const globalLayoutSettings = { | |
| images_per_page: document.getElementById('images_per_page').value, | |
| orientation: document.getElementById('orientation').value, | |
| grid_rows: document.getElementById('grid_rows').value, | |
| grid_cols: document.getElementById('grid_cols').value, | |
| filter_type: document.getElementById('filter_type').value, | |
| practice_mode: document.getElementById('practice_mode').value, | |
| font_size_scale: document.getElementById('font_size_scale').value | |
| }; | |
| localStorage.setItem('pdfGeneratorLayoutSettings', JSON.stringify(globalLayoutSettings)); | |
| } | |
| function loadSettings() { | |
| // Load session-specific settings (subject, tags, etc.) | |
| const sessionSettings = JSON.parse(localStorage.getItem('pdfGeneratorSettings-{{ session_id }}')); | |
| if (sessionSettings) { | |
| const pdfSubjectInput = document.getElementById('pdf_subject'); | |
| if (!pdfSubjectInput.value) { | |
| pdfSubjectInput.value = sessionSettings.pdf_subject || ''; | |
| } | |
| const pdfNameInput = document.getElementById('pdf_name'); | |
| if (!pdfNameInput.value) { | |
| pdfNameInput.value = sessionSettings.pdf_name || 'My-Test-Analysis'; | |
| } | |
| const pdfTagsInput = document.getElementById('pdf_tags'); | |
| if (!pdfTagsInput.value) { | |
| pdfTagsInput.value = sessionSettings.pdf_tags || ''; | |
| } | |
| const pdfNotesInput = document.getElementById('pdf_notes'); | |
| if (!pdfNotesInput.value) { | |
| pdfNotesInput.value = sessionSettings.pdf_notes || ''; | |
| } | |
| } | |
| // Load global layout settings | |
| const globalLayoutSettings = JSON.parse(localStorage.getItem('pdfGeneratorLayoutSettings')); | |
| if (globalLayoutSettings) { | |
| document.getElementById('images_per_page').value = globalLayoutSettings.images_per_page || 4; | |
| document.getElementById('orientation').value = globalLayoutSettings.orientation || 'portrait'; | |
| document.getElementById('grid_rows').value = globalLayoutSettings.grid_rows || 4; | |
| document.getElementById('grid_cols').value = globalLayoutSettings.grid_cols || 1; | |
| document.getElementById('filter_type').value = globalLayoutSettings.filter_type || 'all'; | |
| document.getElementById('practice_mode').value = globalLayoutSettings.practice_mode || 'none'; | |
| if (globalLayoutSettings.font_size_scale) { | |
| document.getElementById('font_size_scale').value = globalLayoutSettings.font_size_scale; | |
| document.getElementById('font_size_val').innerText = globalLayoutSettings.font_size_scale; | |
| } | |
| } | |
| handlePracticeModeChange(); // Apply visibility changes on load | |
| } | |
| function handlePracticeModeChange() { | |
| const practiceMode = document.getElementById('practice_mode').value; | |
| const standardOptions = document.getElementById('standard-layout-options'); | |
| const orientationSelect = document.getElementById('orientation'); | |
| const pdfNameDiv = document.getElementById('pdf-name-div'); | |
| if (practiceMode === 'none') { | |
| standardOptions.style.display = 'block'; | |
| pdfNameDiv.style.display = 'block'; | |
| } else { | |
| standardOptions.style.display = 'none'; | |
| pdfNameDiv.style.display = 'none'; | |
| if (practiceMode === 'landscape_2') { | |
| orientationSelect.value = 'landscape'; | |
| } else { | |
| orientationSelect.value = 'portrait'; | |
| } | |
| } | |
| } | |
| function updateGridDefaults() { | |
| const imagesPerPage = parseInt(document.getElementById('images_per_page').value, 10); | |
| const orientation = document.getElementById('orientation').value; | |
| const rowsInput = document.getElementById('grid_rows'); | |
| const colsInput = document.getElementById('grid_cols'); | |
| if (imagesPerPage === 4) { | |
| if (orientation === 'portrait') { | |
| rowsInput.value = 4; | |
| colsInput.value = 1; | |
| } else { // landscape | |
| rowsInput.value = 2; | |
| colsInput.value = 2; | |
| } | |
| } else { | |
| const cols = Math.ceil(Math.sqrt(imagesPerPage)); | |
| const rows = Math.ceil(imagesPerPage / cols); | |
| rowsInput.value = rows; | |
| colsInput.value = cols; | |
| } | |
| } | |
| function setupEventListeners() { | |
| document.getElementById('json-upload').addEventListener('change', handleJsonUpload); | |
| document.getElementById('images_per_page').addEventListener('change', updateGridDefaults); | |
| document.getElementById('orientation').addEventListener('change', updateGridDefaults); | |
| document.getElementById('practice_mode').addEventListener('change', handlePracticeModeChange); | |
| document.querySelectorAll('input[id^="question_number_"]').forEach(input => { | |
| input.addEventListener('change', handleQuestionNumberChange); | |
| }); | |
| document.addEventListener('keydown', handleShortcuts); | |
| document.querySelectorAll('.status-buttons').forEach(setupStatusButtons); | |
| document.getElementById('questions-form').addEventListener('submit', handleFormSubmit); | |
| document.getElementById('preview-btn').addEventListener('click', handlePreview); | |
| document.getElementById('add-misc-question').addEventListener('click', handleAddMiscQuestion); | |
| document.getElementById('extract-classify-all').addEventListener('click', async () => { | |
| const extractBtn = document.getElementById('extract-classify-all'); | |
| const spinner = extractBtn.querySelector('.spinner-border'); | |
| const text = extractBtn.querySelector('.extract-text'); | |
| spinner.classList.remove('d-none'); | |
| text.textContent = 'Processing...'; | |
| extractBtn.disabled = true; | |
| // First, save the questions | |
| const questions = []; | |
| document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => { | |
| questions.push({ | |
| image_id: fieldset.querySelector('input[name^="image_id_"]').value, | |
| question_number: fieldset.querySelector('input[name^="question_number_"]').value, | |
| status: fieldset.querySelector('select[name^="status_"]').value, | |
| marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value, | |
| actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value | |
| }); | |
| }); | |
| try { | |
| const saveResponse = await fetch('/save_questions', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| questions: questions, | |
| pdf_subject: document.getElementById('pdf_subject').value, | |
| pdf_tags: document.getElementById('pdf_tags').value, | |
| pdf_notes: document.getElementById('pdf_notes').value | |
| }) | |
| }); if (!saveResponse.ok) throw new Error((await saveResponse.json()).error || 'Failed to save questions.'); | |
| // Now, run the classification | |
| const response = await fetch(`/extract_and_classify_all/${sessionId}`, { | |
| method: 'POST' | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| showStatus(result.message, 'success'); | |
| // Reload the page to show the updated data | |
| setTimeout(() => { | |
| location.reload(); | |
| }, 2000); | |
| } else { | |
| showStatus(`Error: ${result.error}`, 'danger'); | |
| } | |
| } catch (err) { | |
| showStatus(`Error: ${err.message}`, 'danger'); | |
| } finally { | |
| spinner.classList.add('d-none'); | |
| text.textContent = 'Extract & Classify All'; | |
| extractBtn.disabled = false; | |
| } | |
| }); | |
| // Add event listeners for auto-extract buttons if NVIDIA NIM is available | |
| {% if nvidia_nim_available %} | |
| document.querySelectorAll('.auto-extract-btn').forEach(button => { | |
| button.addEventListener('click', function() { | |
| const imageId = this.dataset.imageId; | |
| const index = this.dataset.index; | |
| autoExtractQuestionNumber(this, imageId, index); | |
| }); | |
| }); | |
| // Add event listener for auto-extract all button | |
| document.getElementById('auto-extract-all').addEventListener('click', autoExtractAllQuestionNumbers); | |
| // Add event listeners for delete buttons | |
| document.querySelectorAll('.delete-question-btn').forEach(button => { | |
| button.addEventListener('click', function() { | |
| const imageId = this.dataset.imageId; | |
| const index = this.dataset.index; | |
| deleteQuestion(imageId, index); | |
| }); | |
| }); | |
| {% endif %} | |
| } | |
| function handleJsonUpload(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const json = JSON.parse(e.target.result); | |
| if (!json.data || !Array.isArray(json.data)) throw new Error('Invalid format'); | |
| answerKeyData.clear(); | |
| json.data.forEach(item => answerKeyData.set(String(item.question_number), String(item.answer))); | |
| showStatus(`Loaded ${answerKeyData.size} answers from key.`, 'success'); | |
| // Auto-fill answers for existing question numbers | |
| document.querySelectorAll('input[id^="question_number_"]').forEach(input => { | |
| if(input.value) handleQuestionNumberChange.call(input); | |
| }); | |
| } catch (err) { | |
| showStatus('Error reading or parsing JSON file.', 'danger'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function handleQuestionNumberChange() { | |
| const qNum = this.value; | |
| if (answerKeyData.has(qNum)) { | |
| const index = this.id.split('_').pop(); | |
| document.getElementById(`actual_solution_${index}`).value = answerKeyData.get(qNum); | |
| } | |
| } | |
| function getCurrentQuestionIndex() { | |
| const activeElement = document.activeElement; | |
| const parentFieldset = activeElement?.closest('fieldset[data-question-index]'); | |
| return parentFieldset ? parseInt(parentFieldset.dataset.questionIndex, 10) : -1; | |
| } | |
| function setStatusForCurrentQuestion(status) { | |
| const index = getCurrentQuestionIndex(); | |
| if (index !== -1) { | |
| document.querySelector(`.status-buttons[data-index='${index}'] .status-btn[data-status='${status}']`)?.click(); | |
| } | |
| } | |
| function moveToNextQuestion() { | |
| let index = getCurrentQuestionIndex(); | |
| if (index === -1) index = 0; | |
| const nextIndex = (index + 1) % numImages; | |
| document.getElementById(`question_number_${nextIndex}`)?.focus(); | |
| } | |
| function handleShortcuts(e) { | |
| if (e.shiftKey) { | |
| const key = e.key.toLowerCase(); | |
| if (['q', 'c', 'w', 'u'].includes(key)) e.preventDefault(); | |
| switch(key) { | |
| case 'q': moveToNextQuestion(); break; | |
| case 'c': setStatusForCurrentQuestion('correct'); break; | |
| case 'w': setStatusForCurrentQuestion('wrong'); break; | |
| case 'u': setStatusForCurrentQuestion('unattempted'); break; | |
| } | |
| } | |
| } | |
| function setupStatusButtons(group) { | |
| const buttons = group.querySelectorAll('.status-btn'); | |
| const index = group.dataset.index; | |
| const select = document.getElementById(`status_${index}`); | |
| buttons.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| buttons.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| select.value = btn.dataset.status; | |
| }); | |
| }); | |
| } | |
| async function handlePreview(e) { | |
| e.preventDefault(); | |
| saveSettings(); // Save settings for consistency | |
| const statusDiv = document.getElementById('status'); | |
| showStatus('Generating preview...', 'info'); | |
| const questions = []; | |
| document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => { | |
| questions.push({ | |
| image_id: fieldset.querySelector('input[name^="image_id_"]').value, | |
| question_number: fieldset.querySelector('input[name^="question_number_"]').value, | |
| status: fieldset.querySelector('select[name^="status_"]').value, | |
| marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value, | |
| actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value, | |
| }); | |
| }); | |
| try { | |
| const response = await fetch('/generate_preview', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| subject: document.getElementById('pdf_subject').value, | |
| images_per_page: parseInt(document.getElementById('images_per_page').value, 10), | |
| filter_type: document.getElementById('filter_type').value, | |
| orientation: document.getElementById('orientation').value, | |
| grid_rows: parseInt(document.getElementById('grid_rows').value, 10), | |
| grid_cols: parseInt(document.getElementById('grid_cols').value, 10), | |
| practice_mode: document.getElementById('practice_mode').value, | |
| font_size_scale: parseFloat(document.getElementById('font_size_scale').value), | |
| miscellaneous_questions: miscellaneousQuestions, | |
| questions: questions // Pass the main questions too | |
| }) | |
| }); | |
| const result = await response.json(); | |
| if (result.error) throw new Error(result.error); | |
| const previewImg = document.getElementById('preview-img'); | |
| previewImg.src = result.preview_image;; | |
| const previewModal = new bootstrap.Modal(document.getElementById('previewModal')); | |
| previewModal.show(); | |
| showStatus('Preview generated.', 'success'); | |
| } catch (err) { | |
| showStatus(`Error: ${err.message}`, 'danger'); | |
| } | |
| } | |
| async function handleFormSubmit(e) { | |
| e.preventDefault(); | |
| saveSettings(); // Save settings on submit | |
| const statusDiv = document.getElementById('status'); | |
| showStatus('Saving questions and generating PDF...', 'info'); | |
| const questions = []; | |
| document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => { | |
| questions.push({ | |
| image_id: fieldset.querySelector('input[name^="image_id_"]').value, | |
| question_number: fieldset.querySelector('input[name^="question_number_"]').value, | |
| status: fieldset.querySelector('select[name^="status_"]').value, | |
| marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value, | |
| actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value | |
| }); | |
| }); | |
| try { | |
| // First, save the question data. | |
| const saveResponse = await fetch('/save_questions', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| questions: questions, | |
| pdf_subject: document.getElementById('pdf_subject').value, | |
| pdf_tags: document.getElementById('pdf_tags').value, | |
| pdf_notes: document.getElementById('pdf_notes').value | |
| }) | |
| }); | |
| if (!saveResponse.ok) throw new Error((await saveResponse.json()).error || 'Failed to save questions.'); | |
| // Then, generate the PDF. | |
| const pdfResponse = await fetch('/generate_pdf', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| pdf_name: document.getElementById('pdf_name').value, | |
| subject: document.getElementById('pdf_subject').value, | |
| tags: document.getElementById('pdf_tags').value, | |
| notes: document.getElementById('pdf_notes').value, | |
| images_per_page: parseInt(document.getElementById('images_per_page').value, 10), | |
| filter_type: document.getElementById('filter_type').value, | |
| orientation: document.getElementById('orientation').value, | |
| grid_rows: parseInt(document.getElementById('grid_rows').value, 10), | |
| grid_cols: parseInt(document.getElementById('grid_cols').value, 10), | |
| practice_mode: document.getElementById('practice_mode').value, | |
| font_size_scale: parseFloat(document.getElementById('font_size_scale').value), | |
| miscellaneous_questions: miscellaneousQuestions | |
| }) | |
| }); | |
| const result = await pdfResponse.json(); | |
| if (result.error) throw new Error(result.error); | |
| statusDiv.innerHTML = `<a href="/download/${result.pdf_filename}" class="btn btn-success w-100">Download PDF: ${result.pdf_filename}</a>`; | |
| } catch (err) { | |
| showStatus(`Error: ${err.message}`, 'danger'); | |
| } | |
| } | |
| function showStatus(message, type = 'info', duration = 4000) { | |
| const statusContainer = document.getElementById('status'); | |
| const alertId = `alert-${Date.now()}`; | |
| const alert = `<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert"> | |
| ${message} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div>`; | |
| statusContainer.innerHTML = alert; | |
| } | |
| function handleAddMiscQuestion() { | |
| const imageInput = document.getElementById('misc_image'); | |
| const file = imageInput.files[0]; | |
| const question = { | |
| id: `misc_${Date.now()}`, | |
| question_number: document.getElementById('misc_question_number').value, | |
| subject: document.getElementById('misc_subject').value, | |
| status: document.getElementById('misc_status').value, | |
| marked_solution: document.getElementById('misc_marked_solution').value, | |
| actual_solution: document.getElementById('misc_actual_solution').value, | |
| image_data: null | |
| }; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| question.image_data = e.target.result; | |
| miscellaneousQuestions.push(question); | |
| renderMiscellaneousQuestions(); | |
| document.getElementById('misc-question-form').reset(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } else { | |
| miscellaneousQuestions.push(question); | |
| renderMiscellaneousQuestions(); | |
| document.getElementById('misc-question-form').reset(); | |
| } | |
| } | |
| function renderMiscellaneousQuestions() { | |
| const listDiv = document.getElementById('misc-questions-list'); | |
| listDiv.innerHTML = ''; | |
| miscellaneousQuestions.forEach((q, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'alert alert-secondary'; | |
| item.innerHTML = ` | |
| <strong>Q: ${q.question_number}</strong> - ${q.subject} | |
| <button type="button" class="btn-close float-end" data-index="${index}"></button> | |
| `; | |
| listDiv.appendChild(item); | |
| }); | |
| document.querySelectorAll('#misc-questions-list .btn-close').forEach(button => { | |
| button.addEventListener('click', handleRemoveMiscQuestion); | |
| }); | |
| } | |
| function handleRemoveMiscQuestion(event) { | |
| const index = event.target.dataset.index; | |
| miscellaneousQuestions.splice(index, 1); | |
| renderMiscellaneousQuestions(); | |
| } | |
| // Auto-extract question number function | |
| async function autoExtractQuestionNumber(button, imageId, index) { | |
| // Check if NVIDIA NIM is available | |
| {% if not nvidia_nim_available %} | |
| showStatus('NVIDIA NIM OCR feature is not available. Please set the NVIDIA_API_KEY environment variable.', 'warning'); | |
| return; | |
| {% endif %} | |
| const spinner = button.querySelector('.extract-spinner'); | |
| const text = button.querySelector('.extract-text'); | |
| const questionInput = document.getElementById(`question_number_${index}`); | |
| // Show loading state | |
| spinner.classList.remove('d-none'); | |
| text.classList.add('d-none'); | |
| button.disabled = true; | |
| try { | |
| const response = await fetch('/extract_question_number', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image_id: imageId }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| if (result.question_number) { | |
| questionInput.value = result.question_number; | |
| showStatus(`Question number ${result.question_number} extracted successfully!`, 'success'); | |
| } else { | |
| showStatus('No question number found in the image.', 'warning'); | |
| } | |
| } else { | |
| showStatus(`Error: ${result.error}`, 'danger'); | |
| } | |
| } catch (err) { | |
| showStatus(`Error extracting question number: ${err.message}`, 'danger'); | |
| } finally { | |
| // Restore button state | |
| spinner.classList.add('d-none'); | |
| text.classList.remove('d-none'); | |
| button.disabled = false; | |
| } | |
| } | |
| // Auto-extract all question numbers function | |
| async function autoExtractAllQuestionNumbers() { | |
| // Check if NVIDIA NIM is available | |
| {% if not nvidia_nim_available %} | |
| showStatus('NVIDIA NIM OCR feature is not available. Please set the NVIDIA_API_KEY environment variable.', 'warning'); | |
| return; | |
| {% endif %} | |
| const extractAllButton = document.getElementById('auto-extract-all'); | |
| const extractAllSpinner = extractAllButton.querySelector('.spinner-border'); | |
| const extractAllText = extractAllButton.querySelector('.extract-text'); | |
| // Show loading state for the main button | |
| extractAllSpinner.classList.remove('d-none'); | |
| extractAllText.textContent = 'Extracting...'; | |
| extractAllButton.disabled = true; | |
| try { | |
| const response = await fetch('/extract_all_question_numbers', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ session_id: sessionId }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| // Process successful results | |
| result.results.forEach(item => { | |
| if (item.question_number) { | |
| // Find the correct input by using the data-image-id attribute | |
| const questionInput = document.querySelector(`input[data-image-id="${item.image_id}"]`); | |
| if (questionInput) { | |
| questionInput.value = item.question_number; | |
| } | |
| } | |
| }); | |
| // Show status message | |
| let message = `Successfully extracted ${result.results.length} question numbers.`; | |
| if (result.errors && result.errors.length > 0) { | |
| message += ` Failed to extract ${result.errors.length} questions.`; | |
| } | |
| showStatus(message, 'success'); | |
| // Show individual errors if any | |
| if (result.errors && result.errors.length > 0) { | |
| result.errors.forEach(error => { | |
| showStatus(`Error for image ${error.image_id}: ${error.error}`, 'danger'); | |
| }); | |
| } | |
| } else { | |
| showStatus(`Error: ${result.error}`, 'danger'); | |
| } | |
| } catch (err) { | |
| showStatus(`Error extracting question numbers: ${err.message}`, 'danger'); | |
| } finally { | |
| // Restore button state | |
| extractAllSpinner.classList.add('d-none'); | |
| extractAllText.textContent = 'Auto Extract All Question Numbers'; | |
| extractAllButton.disabled = false; | |
| } | |
| } | |
| // Delete question function | |
| async function deleteQuestion(imageId, index) { | |
| if (!confirm('Are you sure you want to delete this question? This action cannot be undone.')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/delete_question/${imageId}`, { | |
| method: 'DELETE' | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| // Remove the fieldset from the DOM | |
| const fieldset = document.getElementById(`question-fieldset-${imageId}`); | |
| if (fieldset) { | |
| fieldset.remove(); | |
| } | |
| showStatus('Question deleted successfully!', 'success'); | |
| } else { | |
| showStatus(`Error: ${result.error}`, 'danger'); | |
| } | |
| } catch (err) { | |
| showStatus(`Error deleting question: ${err.message}`, 'danger'); | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initializeTomSelect(); // Initialize Tom Select here | |
| loadSettings(); | |
| setupEventListeners(); | |
| }); | |
| </script> | |
| {% endblock %} |