diff --git a/components/Landing.tsx b/components/Landing.tsx index 0523c38..a1b56c0 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,14 +1,16 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, ChevronDown, ChevronUp, X, FileText, Settings, Image } from 'lucide-react'; +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: (options: { topic?: string; questionCount?: number; file?: File }) => 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,15 +21,20 @@ interface LandingProps { export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { const auth = useAuth(); const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); + const [generateMode, setGenerateMode] = useState('topic'); const [topic, setTopic] = useState(''); const [pin, setPin] = useState(''); const [name, setName] = useState(''); const [libraryOpen, setLibraryOpen] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [questionCount, setQuestionCount] = useState(10); - const [showAdvanced, setShowAdvanced] = useState(false); 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, @@ -48,21 +55,23 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setSelectedFile(e.target.files[0]); + 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[0]) { - const file = e.dataTransfer.files[0]; - const acceptedTypes = ['.pdf', '.txt', '.md', '.docx', '.jpg', '.jpeg', '.png', '.gif', '.webp']; - const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); - - if (acceptedTypes.includes(fileExtension) || file.type.startsWith('image/')) { - setSelectedFile(file); + 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]); } } }; @@ -77,17 +86,28 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on setIsDragging(false); }; - const removeFile = () => { - setSelectedFile(null); + 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() || selectedFile) && !isLoading) { + if (canGenerate && !isLoading) { onGenerate({ - topic: topic.trim(), + topic: generateMode === 'topic' ? topic.trim() : undefined, questionCount, - file: selectedFile || undefined + files: generateMode === 'document' ? selectedFiles : undefined, + useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined }); } }; @@ -145,120 +165,187 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on {mode === 'HOST' ? ( -
+
- 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} - /> - -
- - - - {showAdvanced && ( - -
-
-
- Question Count - {questionCount} -
- setQuestionCount(Number(e.target.value))} - className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" - /> -
- 5 - 30 -
-
+ +
-
document.getElementById('file-upload')?.click()} - className={`border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-all ${ - isDragging - ? 'border-theme-primary bg-theme-primary/5 scale-[1.02]' - : 'border-gray-300 hover:border-gray-400 hover:bg-gray-50' - }`} - > - - - {selectedFile ? ( -
-
- {selectedFile.type.startsWith('image/') ? ( - - ) : ( - - )} -
- {selectedFile.name} - {(selectedFile.size / 1024).toFixed(1)} KB -
-
- -
- ) : ( -
- -

- {isDragging ? 'Drop file here' : 'Upload Document'} -

-

PDF, TXT, DOCX, Images

-
- )} + + {generateMode === 'topic' ? ( + + 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} + /> + + ) : ( + +
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' + }`} + > + + +
+ +

+ {isDragging ? 'Drop files here' : 'Drop files or click to browse'} +

+

PDF, DOCX, PPTX, XLSX, TXT, Images

+
+
+ + {selectedFiles.length > 0 && ( +
+
+ {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected + +
+
+ {selectedFiles.map((file, index) => ( +
+
+
+ {file.type.startsWith('image/') ? ( + + ) : ( + + )}
+ {file.name} +
+
- + ))} +
+
)} -
+ + {showOcrOption && ( + + )} + + )} + + +
+
+ Questions + {questionCount} +
+ setQuestionCount(Number(e.target.value))} + className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + />
diff --git a/docker-compose.yml b/docker-compose.yml index 0c001fa..0b0a3f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 5bda1be..690b1a9 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -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('HOST'); const [gameState, setGameState] = useState('LANDING'); @@ -43,16 +45,50 @@ export const useGame = () => { // -- HOST LOGIC -- - const startQuizGen = async (topic: string) => { + const uploadDocument = async (file: File, useOcr: boolean = false): Promise => { + 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'); } }; diff --git a/server/package-lock.json b/server/package-lock.json index 4ea764d..53c3e55 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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": "*" + } } } } diff --git a/server/package.json b/server/package.json index 8ba79da..1e412dd 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/index.ts b/server/src/index.ts index 4557eba..886e7a9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts new file mode 100644 index 0000000..c120247 --- /dev/null +++ b/server/src/routes/upload.ts @@ -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; diff --git a/server/src/services/documentParser.ts b/server/src/services/documentParser.ts new file mode 100644 index 0000000..ad72b6f --- /dev/null +++ b/server/src/services/documentParser.ts @@ -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 { + 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 { + 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}`); +} diff --git a/services/geminiService.ts b/services/geminiService.ts index 4e4131a..5e1cd18 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -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 => { - 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"); - } - - const data = JSON.parse(response.text); +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}`; + } + + 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 => { 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 => { + 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); }; diff --git a/tsconfig.json b/tsconfig.json index 2c6eed5..21643e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ ], "skipLibCheck": true, "types": [ - "node" + "node", + "vite/client" ], "moduleResolution": "bundler", "isolatedModules": true, diff --git a/types.ts b/types.ts index 6fc1adc..c9fc7c7 100644 --- a/types.ts +++ b/types.ts @@ -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;