Compare commits

...

3 commits

Author SHA1 Message Date
846ba2a69c
Add time sync 2026-01-13 22:56:56 -07:00
028bab23fd
Add document AI gen support 2026-01-13 22:48:43 -07:00
16007cc3aa
feat(landing): add document upload and quiz customization options
- Added collapsible advanced options section
- Implemented file upload with drag-and-drop support (PDF, TXT, MD, DOCX, Images)
- Added question count slider (5-30)
- Updated Generate button logic to support document-based generation
2026-01-13 20:44:11 -07:00
11 changed files with 1323 additions and 68 deletions

View file

@ -1,14 +1,16 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import type { Quiz } from '../types';
type GenerateMode = 'topic' | 'document';
interface LandingProps {
onGenerate: (topic: string) => void;
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void;
onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz) => void;
onJoin: (pin: string, name: string) => void;
@ -19,11 +21,21 @@ interface LandingProps {
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
const auth = useAuth();
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
const [topic, setTopic] = useState('');
const [pin, setPin] = useState('');
const [name, setName] = useState('');
const [libraryOpen, setLibraryOpen] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [questionCount, setQuestionCount] = useState(10);
const [isDragging, setIsDragging] = useState(false);
const [useOcr, setUseOcr] = useState(false);
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/'));
const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
const showOcrOption = hasImageFile || hasDocumentFile;
const {
quizzes,
loading: libraryLoading,
@ -42,9 +54,62 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files);
setSelectedFiles(prev => [...prev, ...newFiles]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const acceptedTypes = ['.pdf', '.txt', '.md', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods', '.rtf', '.jpg', '.jpeg', '.png', '.gif', '.webp'];
const newFiles = Array.from(e.dataTransfer.files).filter((file: File) => {
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
return acceptedTypes.includes(fileExtension) || file.type.startsWith('image/');
});
if (newFiles.length > 0) {
setSelectedFiles(prev => [...prev, ...newFiles]);
}
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
if (selectedFiles.length <= 1) {
setUseOcr(false);
}
};
const clearAllFiles = () => {
setSelectedFiles([]);
setUseOcr(false);
};
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (topic.trim()) onGenerate(topic);
if (canGenerate && !isLoading) {
onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined,
questionCount,
files: generateMode === 'document' ? selectedFiles : undefined,
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined
});
}
};
const handleJoinSubmit = (e: React.FormEvent) => {
@ -100,22 +165,187 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</div>
{mode === 'HOST' ? (
<div className="space-y-6">
<div className="space-y-4">
<form onSubmit={handleHostSubmit} className="space-y-4">
<input
type="text"
placeholder="Topic (e.g. 'Space')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
disabled={isLoading}
/>
<div className="flex bg-gray-100 p-1.5 rounded-xl mb-2">
<button
type="button"
onClick={() => setGenerateMode('topic')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
generateMode === 'topic'
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<Sparkles size={16} /> Topic
</button>
<button
type="button"
onClick={() => setGenerateMode('document')}
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
generateMode === 'document'
? 'bg-white shadow-sm text-theme-primary'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<FileText size={16} /> Document
</button>
</div>
<AnimatePresence mode="wait">
{generateMode === 'topic' ? (
<motion.div
key="topic"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
<input
type="text"
placeholder="Enter a topic (e.g. 'Space')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
disabled={isLoading}
/>
</motion.div>
) : (
<motion.div
key="document"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="space-y-3"
>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById('file-upload')?.click()}
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
isDragging
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
: selectedFiles.length > 0
? 'border-theme-primary/50 bg-theme-primary/5'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
>
<input
id="file-upload"
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
/>
<div className="space-y-2">
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
<p className="text-sm font-bold text-gray-600">
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
</p>
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
</div>
</div>
{selectedFiles.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearAllFiles();
}}
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
>
Clear all
</button>
</div>
<div className="max-h-32 overflow-y-auto space-y-1.5">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
<div className="flex items-center gap-2 overflow-hidden text-left">
<div className="p-1.5 bg-theme-primary/10 rounded">
{file.type.startsWith('image/') ? (
<Image size={14} className="text-theme-primary" />
) : (
<FileText size={14} className="text-theme-primary" />
)}
</div>
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
</div>
)}
{showOcrOption && (
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
<div className="relative">
<input
type="checkbox"
checked={useOcr}
onChange={(e) => setUseOcr(e.target.checked)}
className="sr-only peer"
/>
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
</div>
<div className="flex items-center gap-2">
<ScanText size={18} className="text-gray-500" />
<span className="text-sm font-bold text-gray-600">
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
</span>
</div>
</label>
)}
</motion.div>
)}
</AnimatePresence>
<div className="space-y-2 pt-2">
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
<span>Questions</span>
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
</div>
<input
type="range"
min="5"
max="30"
value={questionCount}
onChange={(e) => setQuestionCount(Number(e.target.value))}
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
<button
type="submit"
disabled={isLoading || !topic.trim()}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
disabled={isLoading || !canGenerate}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? <Loader2 className="animate-spin" /> : <><BrainCircuit size={24} /> Generate Quiz</>}
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<>
<BrainCircuit size={24} />
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
</>
)}
</button>
</form>

View file

@ -110,7 +110,7 @@ services:
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
volumes:
- kaboot-data:/data
- ./data:/data
ports:
- "${KABOOT_BACKEND_PORT:-3001}:3001"
depends_on:
@ -121,7 +121,6 @@ services:
volumes:
postgresql-data:
redis-data:
kaboot-data:
networks:
kaboot-network:

View file

@ -1,9 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question } from '../types';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument } from '../types';
import { generateQuiz } from '../services/geminiService';
import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants';
import { Peer, DataConnection } from 'peerjs';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
export const useGame = () => {
const [role, setRole] = useState<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING');
@ -43,16 +45,50 @@ export const useGame = () => {
// -- HOST LOGIC --
const startQuizGen = async (topic: string) => {
const uploadDocument = async (file: File, useOcr: boolean = false): Promise<ProcessedDocument> => {
const formData = new FormData();
formData.append('document', file);
formData.append('useOcr', String(useOcr));
const response = await fetch(`${BACKEND_URL}/api/upload`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload document');
}
return response.json();
};
const startQuizGen = async (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => {
try {
setGameState('GENERATING');
setError(null);
setRole('HOST');
const generatedQuiz = await generateQuiz(topic);
setPendingQuizToSave({ quiz: generatedQuiz, topic });
let documents: ProcessedDocument[] | undefined;
if (options.files && options.files.length > 0) {
documents = await Promise.all(
options.files.map(file => uploadDocument(file, options.useOcr))
);
}
const generateOptions: GenerateQuizOptions = {
topic: options.topic,
questionCount: options.questionCount,
documents
};
const generatedQuiz = await generateQuiz(generateOptions);
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
initializeHostGame(generatedQuiz);
} catch (e) {
setError("Failed to generate quiz.");
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
setError(message);
setGameState('LANDING');
}
};
@ -229,10 +265,16 @@ export const useGame = () => {
}
if (timerRef.current) clearInterval(timerRef.current);
let tickCount = 0;
timerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 100) { endQuestion(); return 0; }
return prev - 100;
const newTime = prev - 100;
tickCount++;
if (tickCount % 10 === 0) {
broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } });
}
return newTime;
});
}, 100);
};
@ -336,7 +378,12 @@ export const useGame = () => {
}
}
if (data.type === 'TIME_SYNC') {
setTimeLeft(data.payload.timeLeft);
}
if (data.type === 'TIME_UP') {
if (timerRef.current) clearInterval(timerRef.current);
setGameState('REVEAL');
}

702
server/package-lock.json generated
View file

@ -13,6 +13,8 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.2",
"officeparser": "^6.0.4",
"uuid": "^11.0.5"
},
"devDependencies": {
@ -20,6 +22,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.2",
@ -468,6 +471,262 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
"integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.88",
"@napi-rs/canvas-darwin-arm64": "0.1.88",
"@napi-rs/canvas-darwin-x64": "0.1.88",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.88",
"@napi-rs/canvas-linux-arm64-musl": "0.1.88",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.88",
"@napi-rs/canvas-linux-x64-gnu": "0.1.88",
"@napi-rs/canvas-linux-x64-musl": "0.1.88",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.88",
"@napi-rs/canvas-win32-x64-msvc": "0.1.88"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz",
"integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz",
"integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz",
"integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz",
"integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz",
"integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz",
"integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz",
"integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz",
"integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz",
"integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz",
"integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz",
"integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@ -560,6 +819,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
@ -609,6 +878,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -622,6 +912,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -679,6 +975,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -727,12 +1029,38 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -777,6 +1105,21 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1021,6 +1364,24 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@ -1076,6 +1437,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"license": "MIT",
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@ -1272,6 +1650,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -1313,6 +1697,12 @@
"node": ">= 0.10"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
@ -1621,6 +2011,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@ -1633,6 +2035,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@ -1660,6 +2080,26 @@
"node": ">=10"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -1681,6 +2121,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/officeparser": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/officeparser/-/officeparser-6.0.4.tgz",
"integrity": "sha512-zmxst4xbrUxCCY6w5BLmmtewJY5eHkLKNJ+2Z5OKY0JPh5qmtUSslFkg0fC3xc+0RG4DFdfQ36107B99yLMfuQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.10",
"concat-stream": "^2.0.0",
"file-type": "^16.5.4",
"pdfjs-dist": "5.4.530",
"tesseract.js": "^6.0.0",
"yauzl": "^3.1.3"
},
"bin": {
"officeparser": "dist/index.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1702,6 +2162,15 @@
"wrappy": "1"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1717,6 +2186,37 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
"integrity": "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.84"
}
},
"node_modules/peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@ -1743,6 +2243,15 @@
"node": ">=10"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1834,6 +2343,68 @@
"node": ">= 6"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/readable-web-to-node-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/readable-web-to-node-stream/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -2059,6 +2630,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -2077,6 +2656,23 @@
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@ -2105,6 +2701,30 @@
"node": ">=6"
}
},
"node_modules/tesseract.js": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz",
"integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^6.0.0",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz",
"integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==",
"license": "Apache-2.0"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2114,6 +2734,29 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@ -2159,6 +2802,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2225,17 +2874,70 @@
"node": ">= 0.8"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yauzl": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz",
"integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"pend": "~1.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}

View file

@ -16,6 +16,8 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.2",
"officeparser": "^6.0.4",
"uuid": "^11.0.5"
},
"devDependencies": {
@ -23,6 +25,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.2",

View file

@ -3,6 +3,7 @@ import cors from 'cors';
import { db } from './db/connection.js';
import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js';
import uploadRouter from './routes/upload.js';
const app = express();
const PORT = process.env.PORT || 3001;
@ -58,6 +59,7 @@ app.get('/health', (_req: Request, res: Response) => {
app.use('/api/quizzes', quizzesRouter);
app.use('/api/users', usersRouter);
app.use('/api/upload', uploadRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err);

View file

@ -0,0 +1,71 @@
import { Router } from 'express';
import multer from 'multer';
import { processDocument, SUPPORTED_TYPES } from '../services/documentParser.js';
const router = Router();
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
},
fileFilter: (_req, file, cb) => {
if (SUPPORTED_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type: ${file.mimetype}. Supported types: ${SUPPORTED_TYPES.join(', ')}`));
}
}
});
router.post('/', upload.single('document'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const useOcr = req.body?.useOcr === 'true' || req.body?.useOcr === true;
const processed = await processDocument(req.file.buffer, req.file.mimetype, { useOcr });
if (processed.type === 'native') {
res.json({
type: 'native',
content: (processed.content as Buffer).toString('base64'),
mimeType: processed.mimeType,
originalName: req.file.originalname
});
} else {
res.json({
type: 'text',
content: processed.content as string,
mimeType: processed.mimeType,
originalName: req.file.originalname
});
}
} catch (error) {
console.error('Upload processing error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to process document'
});
}
});
router.use((err: Error, _req: any, res: any, _next: any) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err.message.includes('Unsupported file type')) {
return res.status(400).json({ error: err.message });
}
console.error('Upload error:', err);
res.status(500).json({ error: 'Upload failed' });
});
export default router;

View file

@ -0,0 +1,123 @@
import officeParser from 'officeparser';
// MIME types that Gemini can handle natively (send as-is)
export const GEMINI_NATIVE_TYPES = [
'application/pdf',
'text/plain',
'text/markdown',
'text/csv',
'text/html',
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
];
// MIME types that officeparser can extract text from
export const OFFICEPARSER_TYPES = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.oasis.opendocument.text', // .odt
'application/vnd.oasis.opendocument.presentation', // .odp
'application/vnd.oasis.opendocument.spreadsheet', // .ods
'application/rtf', // .rtf
];
// Image types that can use OCR for text extraction
export const OCR_CAPABLE_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
];
export const SUPPORTED_TYPES = [...GEMINI_NATIVE_TYPES, ...OFFICEPARSER_TYPES];
export interface ProcessedDocument {
type: 'native' | 'text';
content: Buffer | string;
mimeType: string;
}
export interface ProcessOptions {
useOcr?: boolean;
}
export function isSupportedType(mimeType: string): boolean {
return SUPPORTED_TYPES.includes(mimeType);
}
export function isGeminiNative(mimeType: string): boolean {
return GEMINI_NATIVE_TYPES.includes(mimeType);
}
export function isOcrCapable(mimeType: string): boolean {
return OCR_CAPABLE_TYPES.includes(mimeType);
}
export function needsOfficeParser(mimeType: string): boolean {
return OFFICEPARSER_TYPES.includes(mimeType);
}
async function extractWithOfficeParser(buffer: Buffer, useOcr: boolean = false): Promise<string> {
const config = useOcr ? {
extractAttachments: true,
ocr: true,
ocrLanguage: 'eng'
} : {};
const ast = await officeParser.parseOffice(buffer, config);
let text = ast.toText();
if (useOcr && ast.attachments) {
for (const attachment of ast.attachments) {
if (attachment.ocrText) {
text += '\n' + attachment.ocrText;
}
}
}
return text;
}
export async function processDocument(
buffer: Buffer,
mimeType: string,
options: ProcessOptions = {}
): Promise<ProcessedDocument> {
if (!isSupportedType(mimeType)) {
throw new Error(`Unsupported file type: ${mimeType}`);
}
// Images with OCR requested - extract text
if (isOcrCapable(mimeType) && options.useOcr) {
const text = await extractWithOfficeParser(buffer, true);
return {
type: 'text',
content: text,
mimeType: 'text/plain'
};
}
// Gemini-native types (including images without OCR) - pass through
if (isGeminiNative(mimeType)) {
return {
type: 'native',
content: buffer,
mimeType
};
}
// Office documents - extract text, OCR extracts text from embedded images
if (needsOfficeParser(mimeType)) {
const text = await extractWithOfficeParser(buffer, options.useOcr);
return {
type: 'text',
content: text,
mimeType: 'text/plain'
};
}
throw new Error(`No extraction handler for: ${mimeType}`);
}

View file

@ -1,5 +1,5 @@
import { GoogleGenAI, Type } from "@google/genai";
import { Quiz, Question, AnswerOption } from "../types";
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai";
import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument } from "../types";
import { v4 as uuidv4 } from 'uuid';
const getClient = () => {
@ -10,61 +10,62 @@ const getClient = () => {
return new GoogleGenAI({ apiKey });
};
export const generateQuiz = async (topic: string): Promise<Quiz> => {
const ai = getClient();
const prompt = `Generate a trivia quiz about "${topic}". Create 10 engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. For each option, provide a brief reason explaining why it is correct or incorrect - this helps players learn.`;
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
const QUIZ_SCHEMA = {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING, description: "A catchy title for the quiz" },
questions: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING, description: "A catchy title for the quiz" },
questions: {
text: { type: Type.STRING, description: "The question text" },
options: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING, description: "The question text" },
options: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING },
isCorrect: { type: Type.BOOLEAN },
reason: { type: Type.STRING, description: "Brief explanation of why this answer is correct or incorrect" }
},
required: ["text", "isCorrect", "reason"]
},
}
text: { type: Type.STRING },
isCorrect: { type: Type.BOOLEAN },
reason: { type: Type.STRING, description: "Brief explanation of why this answer is correct or incorrect" }
},
required: ["text", "options"]
}
required: ["text", "isCorrect", "reason"]
},
}
},
required: ["title", "questions"]
required: ["text", "options"]
}
}
});
},
required: ["title", "questions"]
};
if (!response.text) {
throw new Error("Failed to generate quiz content");
function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean): string {
const questionCount = options.questionCount || 10;
const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
if (hasDocuments) {
const topicContext = options.topic
? ` Focus on aspects related to "${options.topic}".`
: '';
return `Generate a quiz based on the provided content.${topicContext}
${baseInstructions}`;
}
const data = JSON.parse(response.text);
return `Generate a trivia quiz about "${options.topic}".
${baseInstructions}`;
}
function transformToQuiz(data: any): Quiz {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
// Transform to our internal type with shapes/colors pre-assigned
const questions: Question[] = data.questions.map((q: any) => {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
// Shuffle options so the correct one isn't always first (though Gemini usually randomizes, safety first)
// Actually, to map shapes consistently, let's keep array order but assign props
const options: AnswerOption[] = q.options.map((opt: any, index: number) => ({
text: opt.text,
isCorrect: opt.isCorrect,
@ -85,4 +86,67 @@ export const generateQuiz = async (topic: string): Promise<Quiz> => {
title: data.title,
questions
};
}
async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> {
const buffer = typeof doc.content === 'string'
? Buffer.from(doc.content, 'base64')
: doc.content;
const blob = new Blob([buffer], { type: doc.mimeType });
const uploadedFile = await ai.files.upload({
file: blob,
config: { mimeType: doc.mimeType! }
});
if (!uploadedFile.uri || !uploadedFile.mimeType) {
throw new Error("Failed to upload document to Gemini");
}
return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType };
}
export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => {
const ai = getClient();
const docs = options.documents || [];
const hasDocuments = docs.length > 0;
const prompt = buildPrompt(options, hasDocuments);
let contents: any;
if (hasDocuments) {
const parts: any[] = [];
for (const doc of docs) {
if (doc.type === 'native' && doc.mimeType) {
const uploaded = await uploadNativeDocument(ai, doc);
parts.push(createPartFromUri(uploaded.uri, uploaded.mimeType));
} else if (doc.type === 'text') {
parts.push({ text: doc.content as string });
}
}
parts.push({ text: prompt });
contents = createUserContent(parts);
} else {
contents = prompt;
}
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents,
config: {
responseMimeType: "application/json",
responseSchema: QUIZ_SCHEMA
}
});
if (!response.text) {
throw new Error("Failed to generate quiz content");
}
const data = JSON.parse(response.text);
return transformToQuiz(data);
};

View file

@ -11,7 +11,8 @@
],
"skipLibCheck": true,
"types": [
"node"
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,

View file

@ -51,6 +51,18 @@ export interface QuizListItem {
updatedAt: string;
}
export interface ProcessedDocument {
type: 'native' | 'text';
content: string | Buffer;
mimeType?: string;
}
export interface GenerateQuizOptions {
topic?: string;
questionCount?: number;
documents?: ProcessedDocument[];
}
export interface Player {
id: string;
name: string;
@ -82,6 +94,7 @@ export type NetworkMessage =
}
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } }
| { type: 'TIME_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} }
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
| { type: 'GAME_OVER'; payload: { players: Player[] } };