Fix category management and remove import feature

- Fix category editing: Added GET /api/categories/:categoryId endpoint to fetch single category by ID, fixing 'Category not found' error when editing categories for active year
- Fix picks handling: Filter out picks for deleted categories when submitting, loading, and calculating scores to prevent errors
- Fix scores display: Always calculate scores from pickDetails instead of stored score value to ensure accuracy when categories are removed
- Add picks cleanup: Automatically clean up picks for deleted categories in database when categories are deleted or scores are fetched
- Fix winner clearing: Allow clearing winners by accepting empty/null winnerName in PUT /api/categories/:categoryId/winner
- Enable category number editing: Made category number editable in edit modal with validation and conflict checking; automatically updates all related picks when category number changes
- Add bulk category management: Added bulk select and delete functionality in Liste tab with select all checkbox and bulk delete button
- Remove import feature: Completely removed PDF/image import functionality including OCR, file upload, and all related UI components
- Update dependencies: Removed unused packages (tesseract.js, sharp, pdfjs-dist, canvas) after removing import feature
- Improve file cleanup: Added retry logic for file deletion to handle Windows file locking issues
This commit is contained in:
Mathieu Moran
2025-12-28 00:48:48 -05:00
parent 7d8077774b
commit 6a2ec537e9
12 changed files with 477 additions and 556 deletions
+165 -237
View File
@@ -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);
+95 -30
View File
@@ -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