diff --git a/eng.traineddata b/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/eng.traineddata differ diff --git a/package-lock.json b/package-lock.json index 5388ee3..c25a51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1637,7 +1637,7 @@ "url": "https://github.com/sponsors/mehmet-kozan" } }, - "node_modules/pdfjs-dist": { + "node_modules/pdf-parse/node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", @@ -1790,9 +1790,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/public/control.html b/public/control.html index 5d685a5..a42cf7e 100644 --- a/public/control.html +++ b/public/control.html @@ -892,18 +892,13 @@

Catégories Oscars

-
Importez les catégories depuis un PDF ou créez-les manuellement.
+
Créez et gérez les catégories manuellement.
- -
-
-
-
-
Importer depuis un PDF
-
- Téléchargez le fichier PDF de la feuille de props Oscars. Le système extraira automatiquement les catégories et les nommés. -
- - - -
-
- - -
Année des Oscars pour ces catégories.
-
- -
- - -
Format attendu: feuille de props Oscars avec catégories numérotées.
-
- -
- -
-
-
-
- -
-
-
Astuce
-
- Le PDF doit contenir des catégories numérotées (1., 2., etc.) suivies de leurs nommés. - Vérifiez la liste après import pour corriger si nécessaire. -
-
-
-
-
- -
+
@@ -1023,11 +972,17 @@
Catégories importées
Gérez les catégories existantes
-
- - +
+
+ + + +
+
@@ -1279,7 +1234,7 @@
- +
diff --git a/public/js/app_control.js b/public/js/app_control.js index fd37b69..522deaf 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -2096,38 +2096,9 @@ window.onload = async function () { applyWinnerFormFromCache(); // Categories year dropdowns - const categoriesYearSelect = document.getElementById('categories-year'); const manualYearSelect = document.getElementById('manual-year'); const listYearFilterSelect = document.getElementById('list-year-filter'); - // Populate PDF import year dropdown - if (categoriesYearSelect) { - const previousCategoriesYear = categoriesYearSelect.value; - categoriesYearSelect.innerHTML = ''; - - if (cleanedYears.length === 0) { - const opt = document.createElement('option'); - opt.value = ''; - opt.textContent = 'Aucune année (ajoute un film)'; - opt.disabled = true; - categoriesYearSelect.appendChild(opt); - } else { - cleanedYears.forEach((y) => { - const opt = document.createElement('option'); - opt.value = String(y); - opt.textContent = String(y); - categoriesYearSelect.appendChild(opt); - }); - - const desired = previousCategoriesYear && cleanedYears.map(String).includes(previousCategoriesYear) - ? previousCategoriesYear - : (activeYear && cleanedYears.map(String).includes(String(activeYear))) - ? String(activeYear) - : cleanedYears.length > 0 ? String(cleanedYears[0]) : ''; - if (desired) categoriesYearSelect.value = desired; - } - } - // Populate manual creation year dropdown if (manualYearSelect) { const previousManualYear = manualYearSelect.value; @@ -2761,14 +2732,13 @@ window.onload = async function () { } // Categories management - const importPdfForm = document.getElementById('import-pdf-form'); const createCategoryForm = document.getElementById('create-category-form'); const refreshCategoriesListBtn = document.getElementById('refresh-categories-list'); const categoriesListContainer = document.getElementById('categories-list-container'); const listYearFilter = document.getElementById('list-year-filter'); function showCategoriesAlert(message, type = 'info') { - const alertEl = document.getElementById('categories-import-alert') || document.getElementById('categories-manual-alert'); + const alertEl = document.getElementById('categories-manual-alert'); if (!alertEl) return; alertEl.className = `alert alert-${type}`; alertEl.textContent = message; @@ -2778,44 +2748,6 @@ window.onload = async function () { }, 5000); } - if (importPdfForm) { - importPdfForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const year = parseYear(document.getElementById('categories-year').value); - const fileInput = document.getElementById('pdf-file'); - const file = fileInput.files[0]; - - if (!file || !year) { - showCategoriesAlert('Veuillez remplir tous les champs', 'warning'); - return; - } - - const formData = new FormData(); - formData.append('pdf', file); - formData.append('year', year); - - try { - const res = await fetch('/api/categories/import-pdf', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: formData - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.message || 'Import failed'); - } - - const data = await res.json(); - showCategoriesAlert(`Import réussi: ${data.categories.length} catégories importées`, 'success'); - fileInput.value = ''; - if (refreshCategoriesListBtn) refreshCategoriesListBtn.click(); - } catch (err) { - showCategoriesAlert('Erreur: ' + err.message, 'danger'); - } - }); - } - if (createCategoryForm) { createCategoryForm.addEventListener('submit', async (e) => { e.preventDefault(); @@ -2897,6 +2829,9 @@ window.onload = async function () { + @@ -2906,6 +2841,9 @@ window.onload = async function () { ${categories.map(cat => ` +
+ + # Nom Nommés
+ + ${cat.categoryNumber} ${cat.categoryName} @@ -2923,6 +2861,9 @@ window.onload = async function () {
`; + + // Setup bulk selection handlers + setupBulkCategorySelection(); } catch (err) { categoriesListContainer.innerHTML = `
Erreur: ${err.message}
`; } @@ -2930,17 +2871,21 @@ window.onload = async function () { window.editCategory = async function(categoryId) { try { - // Fetch category details - const res = await fetch(`/api/categories/list`, { + // Fetch category directly by ID + const res = await fetch(`/api/categories/${categoryId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { - throw new Error('Failed to load categories'); + if (res.status === 404) { + showResponse('danger', 'Catégorie non trouvée'); + return; + } + throw new Error('Failed to load category'); } const data = await res.json(); - const category = data.categories.find(c => c._id === categoryId); + const category = data.category; if (!category) { showResponse('danger', 'Catégorie non trouvée'); @@ -3019,10 +2964,27 @@ window.onload = async function () { return; } + const categoryNumber = parseInt(document.getElementById('edit_category_number').value); + if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) { + if (alertEl) { + alertEl.className = 'alert alert-warning'; + alertEl.textContent = 'Le numéro de catégorie doit être entre 1 et 50'; + alertEl.classList.remove('d-none'); + } + return; + } + + // Store button state before async operation + const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer'; + try { saveCategoryChangesBtn.disabled = true; - const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer'; saveCategoryChangesBtn.textContent = 'Enregistrement...'; + + // Hide any previous alerts + if (alertEl) { + alertEl.classList.add('d-none'); + } const res = await fetch(`/api/categories/${categoryId}`, { method: 'PUT', @@ -3031,6 +2993,7 @@ window.onload = async function () { 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ + categoryNumber, categoryName, nominees }) @@ -3051,12 +3014,19 @@ window.onload = async function () { // Reload list await loadCategoriesList(); } catch (err) { + // Show error in modal alert if (alertEl) { alertEl.className = 'alert alert-danger'; alertEl.textContent = 'Erreur: ' + err.message; alertEl.classList.remove('d-none'); + // Scroll to alert to make it visible + alertEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } else { + // Fallback to main response if alert element not found + showResponse('danger', 'Erreur: ' + err.message); } } finally { + // Always restore button state saveCategoryChangesBtn.disabled = false; saveCategoryChangesBtn.textContent = oldText; } @@ -3079,5 +3049,104 @@ window.onload = async function () { loadCategoriesList(); }); } + + // Bulk category selection and deletion + function setupBulkCategorySelection() { + const selectAllCheckbox = document.getElementById('select-all-categories'); + const bulkDeleteBtn = document.getElementById('bulk-delete-categories-btn'); + const bulkDeleteCount = document.getElementById('bulk-delete-count'); + const selectionText = document.getElementById('categories-selection-text'); + const categoryCheckboxes = document.querySelectorAll('.category-checkbox'); + + if (!selectAllCheckbox || !bulkDeleteBtn) return; + + // Select all checkbox handler + selectAllCheckbox.addEventListener('change', function() { + categoryCheckboxes.forEach(checkbox => { + checkbox.checked = this.checked; + }); + updateBulkDeleteButton(); + }); + + // Individual checkbox handlers + categoryCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', function() { + updateBulkDeleteButton(); + // Update select all checkbox state + const allChecked = Array.from(categoryCheckboxes).every(cb => cb.checked); + const someChecked = Array.from(categoryCheckboxes).some(cb => cb.checked); + selectAllCheckbox.checked = allChecked; + selectAllCheckbox.indeterminate = someChecked && !allChecked; + }); + }); + + // Update bulk delete button state + function updateBulkDeleteButton() { + const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked); + const count = selected.length; + if (bulkDeleteCount) { + bulkDeleteCount.textContent = count; + } + bulkDeleteBtn.disabled = count === 0; + if (selectionText) { + selectionText.style.display = count > 0 ? 'block' : 'none'; + } + } + + // Bulk delete handler + bulkDeleteBtn.addEventListener('click', async function() { + const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked); + if (selected.length === 0) return; + + const categoryIds = selected.map(cb => cb.dataset.categoryId); + const categoryNames = selected.map(cb => cb.dataset.categoryName); + + const confirmMessage = `Êtes-vous sûr de vouloir supprimer ${categoryIds.length} catégorie(s)?\n\n${categoryNames.join('\n')}\n\nCette action est irréversible.`; + if (!confirm(confirmMessage)) return; + + try { + bulkDeleteBtn.disabled = true; + const oldText = bulkDeleteBtn.textContent; + bulkDeleteBtn.textContent = 'Suppression...'; + + // Delete categories one by one + let successCount = 0; + let errorCount = 0; + + for (const categoryId of categoryIds) { + try { + const res = await fetch(`/api/categories/${categoryId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + successCount++; + } else { + errorCount++; + } + } catch (err) { + console.error(`Error deleting category ${categoryId}:`, err); + errorCount++; + } + } + + if (successCount > 0) { + showResponse('success', `${successCount} catégorie(s) supprimée(s)${errorCount > 0 ? `, ${errorCount} erreur(s)` : ''}`); + await loadCategoriesList(); + } else { + showResponse('danger', `Erreur lors de la suppression: ${errorCount} erreur(s)`); + } + } catch (err) { + showResponse('danger', 'Erreur: ' + err.message); + } finally { + bulkDeleteBtn.disabled = false; + bulkDeleteBtn.textContent = oldText; + } + }); + + // Initialize button state + updateBulkDeleteButton(); + } }; diff --git a/public/js/app_picks.js b/public/js/app_picks.js index 4b6fd51..e479e5f 100644 --- a/public/js/app_picks.js +++ b/public/js/app_picks.js @@ -39,9 +39,8 @@ let categories = []; let myPicks = {}; - let savedPicks = {}; // Track saved state to detect changes let currentYear = null; - let hasUnsavedChanges = false; + let isSaving = false; // Prevent multiple simultaneous saves async function fetchActiveYear() { try { @@ -133,20 +132,17 @@ const data = await res.json(); if (data.pick && data.pick.picks) { myPicks = {}; - savedPicks = {}; // Reset saved state + // Create a map of existing category numbers + const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber)); + + // Only load picks for categories that still exist data.pick.picks.forEach(p => { - myPicks[p.categoryNumber] = p.selectedNominee; - savedPicks[p.categoryNumber] = p.selectedNominee; // Store saved state + if (existingCategoryNumbers.has(p.categoryNumber)) { + myPicks[p.categoryNumber] = p.selectedNominee; + } }); - hasUnsavedChanges = false; renderCategories(); updateProgress(); - updateUnsavedChangesIndicator(); - } else { - // No picks saved yet - savedPicks = {}; - hasUnsavedChanges = false; - updateUnsavedChangesIndicator(); } } catch (err) { console.error('Error loading picks:', err); @@ -227,71 +223,41 @@ // Re-render to update the display renderCategories(); - checkForUnsavedChanges(); + + // Auto-save after selection change + autoSavePicks(); }); }); updateProgress(); } - function checkForUnsavedChanges() { - // Compare current picks with saved picks - const currentKeys = Object.keys(myPicks).sort(); - const savedKeys = Object.keys(savedPicks).sort(); - - // Check if counts differ - if (currentKeys.length !== savedKeys.length) { - hasUnsavedChanges = true; - updateUnsavedChangesIndicator(); - return; + async function autoSavePicks() { + // Clear any existing timeout to reset the debounce + if (autoSavePicks.timeout) { + clearTimeout(autoSavePicks.timeout); } - // Check if any values differ or keys are different - const allKeys = new Set([...currentKeys, ...savedKeys]); - for (const key of allKeys) { - if (myPicks[key] !== savedPicks[key]) { - hasUnsavedChanges = true; - updateUnsavedChangesIndicator(); + // Debounce: wait for user to stop clicking before saving + autoSavePicks.timeout = setTimeout(async () => { + // Skip if already saving (will retry after current save completes) + if (isSaving) { + // Retry after a short delay + setTimeout(() => autoSavePicks(), 200); return; } - } - - // No changes detected - hasUnsavedChanges = false; - updateUnsavedChangesIndicator(); - } - - function updateUnsavedChangesIndicator() { - const submitBtn = document.getElementById('submit-picks'); - const banner = document.getElementById('unsaved-changes-banner'); - - if (hasUnsavedChanges) { - // Enable button and change style - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.classList.add('btn-warning'); - submitBtn.classList.remove('btn-primary'); + + isSaving = true; + try { + await submitPicks(true); // Pass true for silent mode (no alert) + } catch (err) { + console.error('Auto-save failed:', err); + // Show error alert only on failure + showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger'); + } finally { + isSaving = false; } - // Show banner - if (banner) { - banner.classList.remove('d-none'); - if (!banner.classList.contains('show')) { - banner.classList.add('show'); - } - } - } else { - // Disable button and change style - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.classList.remove('btn-warning'); - submitBtn.classList.add('btn-primary'); - } - // Hide banner - if (banner) { - banner.classList.add('d-none'); - banner.classList.remove('show'); - } - } + }, 250); // Reduced to 250ms for better responsiveness } function updateProgress() { @@ -300,18 +266,20 @@ document.getElementById('picks-progress').textContent = `${completed} / ${total}`; } - async function submitPicks() { + async function submitPicks(silent = false) { const picks = Object.keys(myPicks).map(catNum => ({ categoryNumber: parseInt(catNum), selectedNominee: myPicks[catNum] })); - if (picks.length === 0) { - showAlert('Veuillez sélectionner au moins un choix', 'warning'); - return; + // Allow empty picks to be saved (to clear all selections) + // Only show warning in non-silent mode if user explicitly tries to submit with no picks + if (picks.length === 0 && !silent) { + // Don't block - allow saving empty picks to clear selections } - if (picks.length < categories.length) { + // Skip confirmation dialog in silent mode (auto-save) + if (!silent && picks.length < categories.length) { if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) { return; } @@ -336,11 +304,9 @@ } const data = await res.json(); - // Update saved state after successful save - savedPicks = { ...myPicks }; - hasUnsavedChanges = false; - updateUnsavedChangesIndicator(); - showAlert('Vos choix ont été enregistrés avec succès!', 'success'); + if (!silent) { + showAlert('Vos choix ont été enregistrés avec succès!', 'success'); + } } catch (err) { console.error('Error submitting picks:', err); showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger'); @@ -348,7 +314,6 @@ } // Event listeners - document.getElementById('submit-picks')?.addEventListener('click', submitPicks); document.getElementById('load-my-picks')?.addEventListener('click', () => { loadMyPicks(); showAlert('Choix rechargés', 'info'); @@ -599,7 +564,6 @@ Utilisateur Score Total - % Actions @@ -607,7 +571,6 @@ ${validScores.map((score, index) => { // Use the pre-calculated actualScore (always use it, even if 0) const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0); - const percentage = totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0; const rank = index + 1; const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; return ` @@ -616,7 +579,6 @@ ${score.userName} ${actualScore} ${totalCategories} - ${percentage}%
-
- -
Chargement des catégories…
@@ -206,7 +200,6 @@ 0 / 0 gagnants marqués
-
diff --git a/public/user-stat.html b/public/user-stat.html index d933144..a4383b6 100644 --- a/public/user-stat.html +++ b/public/user-stat.html @@ -111,10 +111,9 @@ # Nom - Films + Films Bon picks - Points - Ratio + Points diff --git a/routes/categories.js b/routes/categories.js index c439162..fb6ce30 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -1,26 +1,12 @@ const express = require("express"); const jwt = require("jsonwebtoken"); -const multer = require("multer"); const path = require("path"); const fs = require("fs"); -const { PDFParse } = require("pdf-parse"); const OscarCategory = require("../models/OscarCategory"); +const OscarPick = require("../models/OscarPick"); const router = express.Router(); -// Configure multer for PDF uploads -const upload = multer({ - dest: path.join(__dirname, '../uploads/'), - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit - fileFilter: (req, file, cb) => { - if (file.mimetype === 'application/pdf') { - cb(null, true); - } else { - cb(new Error('Only PDF files are allowed')); - } - } -}); - // Middleware to verify token function verifyToken(req, res, next) { const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ') @@ -59,8 +45,10 @@ function cleanNomineeName(line) { // Helper function to check if a line is a valid nominee function isValidNomineeLine(line, skipPatterns) { - // Skip if it looks like a category header - if (line.match(/^(.+?)[\t\s]+\d+$/) || line.match(/^\d+\./)) { + // Skip if it looks like a category header (number first or name first with number) + if (line.match(/^(\d+)[\t\s]+(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)/i) || + line.match(/^(.+?)[\t\s]+\d+$/) || + line.match(/^\d+\./)) { return false; } @@ -74,6 +62,11 @@ function isValidNomineeLine(line, skipPatterns) { return false; } + // Skip lines that are just numbers or dashes + if (nomineeName.match(/^-?\s*\d+\s*-?$/) || nomineeName.match(/^-+\s*$/)) { + return false; + } + const upperLine = nomineeName.toUpperCase(); // Skip if it looks like a header (but allow category names that might appear in nominees with commas) const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) || @@ -83,218 +76,7 @@ function isValidNomineeLine(line, skipPatterns) { return !isHeader; } -// Parse PDF text and extract categories/nominees -async function parsePDFText(pdfText) { - const categories = []; - const lines = pdfText.split('\n').map(l => l.trim()).filter(l => l.length > 0); - - // First pass: identify all category headers and their positions - const categoryHeaders = []; - const skipPatterns = /^(COVERS|OSCARS|PARTY|PROPS|SHEET|YOUR|NAME|SCORE|____|Your Name|Your Score)/i; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Skip header/metadata lines - if (skipPatterns.test(line)) { - continue; - } - - // Match category in format: "Category Name\tNumber" or "Category Name Number" - const categoryMatch = line.match(/^(.+?)[\t\s]+(\d+)$/); - if (categoryMatch) { - const categoryName = categoryMatch[1].trim().replace(/\*\*/g, '').trim(); - const categoryNumber = parseInt(categoryMatch[2]); - - // Skip invalid entries - if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) { - continue; - } - - // Filter out false positives - must contain common Oscar category keywords - const upperName = categoryName.toUpperCase(); - const isOscarCategory = upperName.includes('BEST') || - upperName.includes('DIRECTOR') || - upperName.includes('ACTOR') || - upperName.includes('ACTRESS') || - upperName.includes('PICTURE') || - upperName.includes('SCREENPLAY') || - upperName.includes('EDITING') || - upperName.includes('CINEMATOGRAPHY') || - upperName.includes('COSTUME') || - upperName.includes('SOUND') || - upperName.includes('VISUAL') || - upperName.includes('EFFECTS') || - upperName.includes('ORIGINAL') || - upperName.includes('ADAPTED') || - upperName.includes('DOCUMENTARY') || - upperName.includes('ANIMATED') || - upperName.includes('INTERNATIONAL') || - upperName.includes('PRODUCTION') || - upperName.includes('MAKEUP') || - upperName.includes('HAIRSTYLING') || - upperName.includes('LIVE-ACTION') || - upperName.includes('SHORT'); - - // Also allow "Makeup and Hairstyling" format - if (!isOscarCategory && !upperName.match(/^(MAKEUP|PRODUCTION|SOUND)$/)) { - continue; - } - - categoryHeaders.push({ - index: i, - categoryNumber: categoryNumber, - categoryName: categoryName, - rawText: line - }); - continue; - } - - // Also try format: "Number. Category Name" (fallback) - const altCategoryMatch = line.match(/^(\d+)\.\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*-)?$/); - if (altCategoryMatch) { - const categoryNumber = parseInt(altCategoryMatch[1]); - let categoryName = altCategoryMatch[2].trim().replace(/\*\*/g, '').replace(/^-\s*/, '').trim(); - - if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) { - continue; - } - - categoryHeaders.push({ - index: i, - categoryNumber: categoryNumber, - categoryName: categoryName, - rawText: line - }); - } - } - - // Sort category headers by their position in the document - categoryHeaders.sort((a, b) => a.index - b.index); - - // Second pass: collect nominees for each category - // In the PDF, nominees can appear BEFORE their category header - for (let h = 0; h < categoryHeaders.length; h++) { - const header = categoryHeaders[h]; - const prevHeaderIndex = h > 0 ? categoryHeaders[h - 1].index : -1; - const nextHeaderIndex = h < categoryHeaders.length - 1 ? categoryHeaders[h + 1].index : lines.length; - - const nominees = []; - - // Collect nominees that appear AFTER this category header (until next category) - // Focus on nominees after the header for better accuracy - for (let i = header.index + 1; i < nextHeaderIndex; i++) { - const line = lines[i]; - if (isValidNomineeLine(line, skipPatterns)) { - const nomineeName = cleanNomineeName(line); - // Skip obviously wrong entries like "- 1 of 1 --" - if (nomineeName && !nomineeName.match(/^-?\s*\d+\s*of\s*\d+\s*-?$/)) { - nominees.push({ name: nomineeName, isWinner: false }); - } - } - } - - // Remove duplicates - const uniqueNominees = []; - const seen = new Set(); - for (const nom of nominees) { - const key = nom.name.toLowerCase().trim(); - if (key && !seen.has(key)) { - seen.add(key); - uniqueNominees.push(nom); - } - } - - // Only add category if it has nominees - if (uniqueNominees.length > 0) { - categories.push({ - categoryNumber: header.categoryNumber, - categoryName: header.categoryName, - nominees: uniqueNominees, - rawText: header.rawText - }); - } - } - - // Sort categories by category number - categories.sort((a, b) => a.categoryNumber - b.categoryNumber); - - return categories; -} -// Import categories from PDF (admin only) -router.post("/import-pdf", verifyToken, upload.single('pdf'), async (req, res) => { - try { - if (!isAdmin(req.user)) { - return res.status(403).json({ message: "You do not have admin privileges" }); - } - - if (!req.file) { - return res.status(400).json({ message: "No PDF file uploaded" }); - } - - const year = parseOscarYear(req.body.year) || new Date().getFullYear(); - const pdfPath = req.file.path; - - // Parse PDF - let pdfText; - try { - const pdfBuffer = fs.readFileSync(pdfPath); - const parser = new PDFParse({ data: pdfBuffer }); - const textResult = await parser.getText(); - pdfText = textResult.text; - - // Cleanup parser - await parser.destroy(); - } catch (err) { - // Cleanup file - fs.unlinkSync(pdfPath); - return res.status(400).json({ message: `Error parsing PDF: ${err.message}` }); - } - - // Cleanup uploaded file - fs.unlinkSync(pdfPath); - - // Parse categories from text - const parsedCategories = await parsePDFText(pdfText); - - if (parsedCategories.length === 0) { - return res.status(400).json({ message: "No categories found in PDF. Please check the format." }); - } - - // Save categories to database - const savedCategories = []; - for (const cat of parsedCategories) { - const category = await OscarCategory.findOneAndUpdate( - { year, categoryNumber: cat.categoryNumber }, - { - year, - categoryNumber: cat.categoryNumber, - categoryName: cat.categoryName, - nominees: cat.nominees, - rawText: cat.rawText - }, - { upsert: true, new: true } - ); - savedCategories.push(category); - } - - res.json({ - message: `Successfully imported ${savedCategories.length} categories`, - year, - categories: savedCategories - }); - } catch (err) { - console.error("Error importing PDF:", err); - // Cleanup file if it exists - if (req.file && req.file.path) { - try { - fs.unlinkSync(req.file.path); - } catch (_) {} - } - res.status(500).json({ error: err.message }); - } -}); // Manually create/update categories (admin only) router.post("/create", verifyToken, async (req, res) => { @@ -352,6 +134,26 @@ router.get("/list", verifyToken, async (req, res) => { } }); +// Get a single category by ID (admin only) - must come after /list route +router.get("/:categoryId", verifyToken, async (req, res) => { + try { + if (!isAdmin(req.user)) { + return res.status(403).json({ message: "You do not have admin privileges" }); + } + + const category = await OscarCategory.findById(req.params.categoryId).lean(); + + if (!category) { + return res.status(404).json({ message: "Category not found" }); + } + + res.json({ category }); + } catch (err) { + console.error("Error fetching category:", err); + res.status(500).json({ error: err.message }); + } +}); + // Update a category (admin only) router.put("/:categoryId", verifyToken, async (req, res) => { try { @@ -359,23 +161,96 @@ router.put("/:categoryId", verifyToken, async (req, res) => { return res.status(403).json({ message: "You do not have admin privileges" }); } - const { categoryName, nominees } = req.body; + const { categoryNumber, categoryName, nominees } = req.body; if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) { return res.status(400).json({ message: "categoryName and nominees array are required" }); } + if (categoryNumber !== undefined && categoryNumber !== null) { + const categoryNum = parseInt(categoryNumber); + if (!Number.isInteger(categoryNum) || categoryNum < 1 || categoryNum > 50) { + return res.status(400).json({ message: "categoryNumber must be between 1 and 50" }); + } + } + const nomineesArray = nominees.map(n => ({ name: typeof n === 'string' ? n : n.name, isWinner: typeof n === 'object' ? (n.isWinner || false) : false })); + // Get the current category to check year and handle categoryNumber conflicts + const currentCategory = await OscarCategory.findById(req.params.categoryId); + if (!currentCategory) { + return res.status(404).json({ message: "Category not found" }); + } + + const year = currentCategory.year; + const updateData = { + categoryName, + nominees: nomineesArray + }; + + // If categoryNumber is being changed, check for conflicts and update existing picks + if (categoryNumber !== undefined && categoryNumber !== null) { + const newCategoryNum = parseInt(categoryNumber); + if (newCategoryNum !== currentCategory.categoryNumber) { + const oldCategoryNum = currentCategory.categoryNumber; + + // Check if another category with this number already exists for this year + const existingCategory = await OscarCategory.findOne({ + year, + categoryNumber: newCategoryNum, + _id: { $ne: req.params.categoryId } + }); + + if (existingCategory) { + return res.status(400).json({ + message: `Category number ${newCategoryNum} already exists for year ${year}` + }); + } + + // Update all existing picks that reference the old category number + // Match by old categoryNumber and old categoryName to ensure we're updating the right picks + const picksToUpdate = await OscarPick.find({ year }); + let updatedPicksCount = 0; + + for (const pick of picksToUpdate) { + if (pick.picks && Array.isArray(pick.picks)) { + let needsUpdate = false; + const updatedPicks = pick.picks.map(p => { + // Match by both old categoryNumber and old categoryName to be safe + if (p.categoryNumber === oldCategoryNum && p.categoryName === currentCategory.categoryName) { + needsUpdate = true; + return { + ...p, + categoryNumber: newCategoryNum, + categoryName: categoryName // Update categoryName too if it changed + }; + } + return p; + }); + + if (needsUpdate) { + pick.picks = updatedPicks; + await pick.save(); + updatedPicksCount++; + } + } + } + + updateData.categoryNumber = newCategoryNum; + + // Log the update (optional, for debugging) + if (updatedPicksCount > 0) { + console.log(`Updated ${updatedPicksCount} pick(s) when changing category number from ${oldCategoryNum} to ${newCategoryNum}`); + } + } + } + const category = await OscarCategory.findByIdAndUpdate( req.params.categoryId, - { - categoryName, - nominees: nomineesArray - }, + updateData, { new: true } ); @@ -398,15 +273,23 @@ router.put("/:categoryId/winner", verifyToken, async (req, res) => { } const { winnerName } = req.body; - if (!winnerName) { - return res.status(400).json({ message: "winnerName is required" }); - } + const isClearing = !winnerName || winnerName === '' || winnerName === null; const category = await OscarCategory.findById(req.params.categoryId); if (!category) { return res.status(404).json({ message: "Category not found" }); } + if (isClearing) { + // Clear all winners (set all nominees to not winners) + category.nominees = category.nominees.map(n => ({ + ...n.toObject(), + isWinner: false + })); + await category.save(); + return res.json({ message: "Winner cleared successfully", category }); + } + // Mark all nominees as not winners, then mark the selected one as winner category.nominees = category.nominees.map(n => ({ ...n.toObject(), @@ -422,6 +305,43 @@ router.put("/:categoryId/winner", verifyToken, async (req, res) => { } }); +// Helper function to clean up picks for deleted categories +async function cleanupPicksForDeletedCategories(year) { + try { + const categories = await OscarCategory.find({ year }).lean(); + const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); + + const allPicks = await OscarPick.find({ year }); + let totalCleaned = 0; + + for (const pick of allPicks) { + if (pick.picks && Array.isArray(pick.picks)) { + const originalLength = pick.picks.length; + pick.picks = pick.picks.filter(p => { + const category = categoryMap.get(p.categoryNumber); + if (!category) return false; // Category was deleted + // Also check if nominee still exists in category + return category.nominees.some(n => n.name === p.selectedNominee); + }); + + if (pick.picks.length !== originalLength) { + await pick.save(); + totalCleaned += (originalLength - pick.picks.length); + } + } + } + + if (totalCleaned > 0) { + console.log(`Cleaned up ${totalCleaned} pick(s) for deleted categories in year ${year}`); + } + + return totalCleaned; + } catch (err) { + console.error("Error cleaning up picks:", err); + throw err; + } +} + // Delete a category (admin only) router.delete("/:categoryId", verifyToken, async (req, res) => { try { @@ -429,11 +349,19 @@ router.delete("/:categoryId", verifyToken, async (req, res) => { return res.status(403).json({ message: "You do not have admin privileges" }); } - const category = await OscarCategory.findByIdAndDelete(req.params.categoryId); + const category = await OscarCategory.findById(req.params.categoryId); if (!category) { return res.status(404).json({ message: "Category not found" }); } + const year = category.year; + + // Delete the category + await OscarCategory.findByIdAndDelete(req.params.categoryId); + + // Clean up picks for deleted categories + await cleanupPicksForDeletedCategories(year); + res.json({ message: "Category deleted successfully" }); } catch (err) { console.error("Error deleting category:", err); diff --git a/routes/picks.js b/routes/picks.js index 8b71bd9..d660707 100644 --- a/routes/picks.js +++ b/routes/picks.js @@ -68,8 +68,8 @@ router.post("/submit", verifyToken, async (req, res) => { const userId = req.user.id; const yearNum = parseOscarYear(year) || await getOrInitActiveYear(); - if (!Array.isArray(picks) || picks.length === 0) { - return res.status(400).json({ message: "Picks array is required" }); + if (!Array.isArray(picks)) { + return res.status(400).json({ message: "Picks must be an array" }); } // Verify categories exist for this year @@ -78,31 +78,38 @@ router.post("/submit", verifyToken, async (req, res) => { return res.status(400).json({ message: "No categories found for this year. Please import categories first." }); } - // Validate picks + // Filter and validate picks - remove picks for deleted categories const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); - for (const pick of picks) { - if (!pick.categoryNumber || !pick.selectedNominee) { - return res.status(400).json({ message: "Each pick must have categoryNumber and selectedNominee" }); - } - const category = categoryMap.get(pick.categoryNumber); - if (!category) { - return res.status(400).json({ message: `Category ${pick.categoryNumber} not found` }); - } - const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee); - if (!nomineeExists) { - return res.status(400).json({ message: `Nominee "${pick.selectedNominee}" not found in category ${pick.categoryNumber}` }); + const validPicks = []; + + if (picks.length > 0) { + for (const pick of picks) { + if (!pick.categoryNumber || !pick.selectedNominee) { + continue; // Skip invalid picks + } + const category = categoryMap.get(pick.categoryNumber); + if (!category) { + // Category was deleted - skip this pick + continue; + } + const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee); + if (!nomineeExists) { + // Nominee doesn't exist in this category - skip this pick + continue; + } + validPicks.push({ + categoryNumber: pick.categoryNumber, + categoryName: category.categoryName, + selectedNominee: pick.selectedNominee + }); } } - // Create or update picks + // Create or update picks (only with valid picks) const pickData = { userId, year: yearNum, - picks: picks.map(p => ({ - categoryNumber: p.categoryNumber, - categoryName: categoryMap.get(p.categoryNumber).categoryName, - selectedNominee: p.selectedNominee - })) + picks: validPicks }; const existingPick = await OscarPick.findOne({ userId, year: yearNum }); @@ -128,15 +135,32 @@ router.get("/my-picks", verifyToken, async (req, res) => { const userId = req.user.id; const year = parseOscarYear(req.query.year) || await getOrInitActiveYear(); - const pick = await OscarPick.findOne({ userId, year }) - .populate('userId', 'name email') - .lean(); + const pick = await OscarPick.findOne({ userId, year }); if (!pick) { return res.json({ pick: null, year }); } - res.json({ pick, year }); + // Clean up picks for deleted categories + const categories = await OscarCategory.find({ year }).lean(); + const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); + + if (pick.picks && Array.isArray(pick.picks)) { + const originalLength = pick.picks.length; + pick.picks = pick.picks.filter(p => { + const category = categoryMap.get(p.categoryNumber); + if (!category) return false; // Category was deleted + // Also check if nominee still exists in category + return category.nominees.some(n => n.name === p.selectedNominee); + }); + + // If picks were cleaned up, save the updated pick + if (pick.picks.length !== originalLength) { + await pick.save(); + } + } + + res.json({ pick: pick.toObject(), year }); } catch (err) { console.error("Error fetching user picks:", err); res.status(500).json({ error: err.message }); @@ -199,13 +223,23 @@ router.post("/calculate-scores", verifyToken, async (req, res) => { .populate('userId', 'name email') .lean(); + // Create a map of all categories for reference + const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); + // Calculate scores const scores = []; for (const pick of allPicks) { let correctCount = 0; const pickDetails = []; + // Only process picks for categories that still exist pick.picks.forEach(p => { + const category = categoryMap.get(p.categoryNumber); + if (!category) { + // Category was deleted - skip this pick + return; + } + const correctWinner = categoryWinners.get(p.categoryNumber); const isCorrect = correctWinner && p.selectedNominee === correctWinner; if (isCorrect) correctCount++; @@ -219,8 +253,12 @@ router.post("/calculate-scores", verifyToken, async (req, res) => { }); }); - // Update the pick with score - await OscarPick.findByIdAndUpdate(pick._id, { score: correctCount }); + // Update the pick with score and clean up deleted category picks + const validPicks = pick.picks.filter(p => categoryMap.has(p.categoryNumber)); + await OscarPick.findByIdAndUpdate(pick._id, { + score: correctCount, + picks: validPicks + }); scores.push({ userId: pick.userId?._id || pick.userId, @@ -253,12 +291,33 @@ router.get("/scores", verifyToken, async (req, res) => { try { const year = parseOscarYear(req.query.year) || await getOrInitActiveYear(); + // Clean up picks for deleted categories before fetching scores + const categories = await OscarCategory.find({ year }).lean(); + const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); + + const picksToClean = await OscarPick.find({ year }); + for (const pick of picksToClean) { + if (pick.picks && Array.isArray(pick.picks)) { + const originalLength = pick.picks.length; + pick.picks = pick.picks.filter(p => { + const category = categoryMap.get(p.categoryNumber); + if (!category) return false; // Category was deleted + // Also check if nominee still exists in category + return category.nominees.some(n => n.name === p.selectedNominee); + }); + + if (pick.picks.length !== originalLength) { + await pick.save(); + } + } + } + const picks = await OscarPick.find({ year }) .populate('userId', 'name email') .sort({ score: -1, submittedAt: 1 }) .lean(); - const categories = await OscarCategory.find({ year }).lean(); + // Reuse categories already fetched for cleanup const categoryWinners = new Map(); categories.forEach(cat => { const winner = cat.nominees.find(n => n.isWinner); @@ -274,13 +333,19 @@ router.get("/scores", verifyToken, async (req, res) => { // Create a map of all categories for reference const categoryMap = new Map(categories.map(c => [c.categoryNumber, c])); + // Only process picks for categories that still exist pick.picks.forEach(p => { + const category = categoryMap.get(p.categoryNumber); + if (!category) { + // Category was deleted - skip this pick + return; + } + const correctWinner = categoryWinners.get(p.categoryNumber); const isCorrect = correctWinner && p.selectedNominee === correctWinner; if (isCorrect) correctCount++; - const category = categoryMap.get(p.categoryNumber); - const allNominees = category ? category.nominees.map(n => n.name) : []; + const allNominees = category.nominees.map(n => n.name); pickDetails.push({ categoryNumber: p.categoryNumber, @@ -296,7 +361,7 @@ router.get("/scores", verifyToken, async (req, res) => { userId: pick.userId?._id || pick.userId, userName: pick.userId?.name || 'Unknown', userEmail: pick.userId?.email || '', - score: pick.score !== null && pick.score !== undefined ? pick.score : correctCount, + score: correctCount, // Always use the calculated score from existing categories totalCategories: categories.length, pickDetails, submittedAt: pick.submittedAt diff --git a/uploads/58f9b0fe6557029827201d966a91c8de b/uploads/58f9b0fe6557029827201d966a91c8de new file mode 100644 index 0000000..3c22fb3 Binary files /dev/null and b/uploads/58f9b0fe6557029827201d966a91c8de differ diff --git a/uploads/f053337f2e65c8f7651ba46febc4fc1f b/uploads/f053337f2e65c8f7651ba46febc4fc1f new file mode 100644 index 0000000..3c22fb3 Binary files /dev/null and b/uploads/f053337f2e65c8f7651ba46febc4fc1f differ