/** * Development Server - Simple HTTP server for local development * Handles static files, CORS, and development features */ import { createServer } from 'http'; import { readFile, writeFile, stat, readdir, mkdir } from 'fs/promises'; import { join, extname } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PORT = process.env.PORT || 8080; const HOST = process.env.HOST || 'localhost'; // MIME types for different file extensions const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4' }; const server = createServer(async (req, res) => { try { // Parse URL and remove query parameters const urlPath = new URL(req.url, `http://${req.headers.host}`).pathname; // Default to index.html for root requests const filePath = urlPath === '/' ? 'index.html' : urlPath.slice(1); const fullPath = join(__dirname, filePath); console.log(`${new Date().toISOString()} - ${req.method} ${urlPath}`); // Legacy API endpoint to get all available books (deprecated - use ContentLoader) if (urlPath === '/api/books') { console.warn('⚠️ /api/books is deprecated. Use ContentLoader.loadBooks() instead'); return await handleBooksAPI(res); } // API endpoint for LLM configuration (IAEngine) if (urlPath === '/api/llm-config') { return await handleLLMConfigAPI(req, res); } // Progress API endpoints (DRS and Flashcards only) if (urlPath === '/api/progress/save') { return await handleProgressSave(req, res); } // Data merge endpoint for combining local and external sources if (urlPath === '/api/progress/merge') { return await handleProgressMerge(req, res); } // Sync status endpoint if (urlPath === '/api/progress/sync-status') { return await handleSyncStatus(req, res); } // DRS progress load: /api/progress/load/drs/bookId/chapterId const drsLoadMatch = urlPath.match(/^\/api\/progress\/load\/drs\/([^\/]+)\/([^\/]+)$/); if (drsLoadMatch) { const [, bookId, chapterId] = drsLoadMatch; return await handleProgressLoad(req, res, 'drs', bookId, chapterId); } // Flashcards progress load: /api/progress/load/flashcards if (urlPath === '/api/progress/load/flashcards') { return await handleProgressLoad(req, res, 'flashcards'); } // Set CORS headers for all requests res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // Disable caching completely for development res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.setHeader('Surrogate-Control', 'no-store'); // Handle preflight requests if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Check if file exists try { const stats = await stat(fullPath); if (stats.isDirectory()) { // Try to serve index.html from directory const indexPath = join(fullPath, 'index.html'); try { await stat(indexPath); return serveFile(indexPath, res); } catch { return send404(res, `Directory listing not allowed for ${urlPath}`); } } return serveFile(fullPath, res); } catch (error) { if (error.code === 'ENOENT') { return send404(res, `File not found: ${urlPath}`); } throw error; } } catch (error) { console.error('Server error:', error); res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); } }); async function handleBooksAPI(res) { try { const booksDir = join(__dirname, 'content', 'books'); const files = await readdir(booksDir); const jsonFiles = files.filter(file => file.endsWith('.json')); const books = []; for (const file of jsonFiles) { try { const filePath = join(booksDir, file); const content = await readFile(filePath, 'utf8'); const data = JSON.parse(content); books.push({ id: data.id, name: data.name, description: data.description, difficulty: data.difficulty, language: data.language, chapters: data.chapters || [] }); } catch (error) { console.error(`Error reading ${file}:`, error); } } res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(books, null, 2)); console.log(` ✅ Served API books list (${books.length} books)`); } catch (error) { console.error('Error in books API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to load books' })); } } async function handleChaptersAPI(res, bookId) { try { const booksDir = join(__dirname, 'content', 'books'); const bookPath = join(booksDir, `${bookId}.json`); const content = await readFile(bookPath, 'utf8'); const bookData = JSON.parse(content); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(bookData.chapters || [], null, 2)); console.log(` ✅ Served API chapters for book ${bookId} (${bookData.chapters?.length || 0} chapters)`); } catch (error) { console.error(`Error in chapters API for ${bookId}:`, error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to load chapters' })); } } async function handleChapterContentAPI(res, chapterId) { try { const chaptersDir = join(__dirname, 'content', 'chapters'); const chapterPath = join(chaptersDir, `${chapterId}.json`); const content = await readFile(chapterPath, 'utf8'); const chapterData = JSON.parse(content); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(chapterData, null, 2)); console.log(` ✅ Served API content for chapter ${chapterId}`); } catch (error) { console.error(`Error in chapter content API for ${chapterId}:`, error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to load chapter content' })); } } async function serveFile(filePath, res) { try { const ext = extname(filePath).toLowerCase(); const mimeType = MIME_TYPES[ext] || 'application/octet-stream'; // Set content type res.setHeader('Content-Type', mimeType); // Set cache headers for static assets if (['.css', '.png', '.jpg', '.gif', '.svg', '.ico', '.woff', '.woff2'].includes(ext)) { res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour } else { res.setHeader('Cache-Control', 'no-cache'); // No cache for HTML and JS files (development) } // Add security headers if (ext === '.js') { res.setHeader('X-Content-Type-Options', 'nosniff'); } // Read and serve file const content = await readFile(filePath); res.writeHead(200); res.end(content); console.log(` ✅ Served ${filePath} (${content.length} bytes, ${mimeType})`); } catch (error) { console.error(`Error serving file ${filePath}:`, error); res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Error reading file'); } } function send404(res, message = 'Not Found') { const html404 = ` 404 - Not Found

404

${message}

← Back to Class Generator

`; res.writeHead(404, { 'Content-Type': 'text/html' }); res.end(html404); console.log(` ❌ 404: ${message}`); } // Progress storage functions for DRS and Flashcards async function handleProgressSave(req, res) { try { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } // Read request body let body = ''; req.on('data', chunk => body += chunk.toString()); req.on('end', async () => { try { const { system, bookId, chapterId, progressData } = JSON.parse(body); // Validate system (only DRS and Flashcards allowed) if (!['drs', 'flashcards'].includes(system)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid system. Only "drs" and "flashcards" allowed' })); return; } // Create saves directory if it doesn't exist const savesDir = join(__dirname, 'saves'); if (!existsSync(savesDir)) { await mkdir(savesDir, { recursive: true }); } // Create filename based on system and identifiers const filename = system === 'drs' ? `${system}-progress-${bookId}-${chapterId}.json` : `${system}-progress.json`; const filePath = join(savesDir, filename); // Add metadata const saveData = { ...progressData, system, bookId: system === 'drs' ? bookId : undefined, chapterId: system === 'drs' ? chapterId : undefined, savedAt: new Date().toISOString(), version: '1.0' }; await writeFile(filePath, JSON.stringify(saveData, null, 2)); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify({ success: true, filename, savedAt: saveData.savedAt })); console.log(` ✅ Saved ${system} progress: ${filename}`); } catch (parseError) { console.error('Error parsing progress save request:', parseError); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON data' })); } }); } catch (error) { console.error('Error in progress save API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to save progress' })); } } async function handleProgressLoad(req, res, system, bookId, chapterId) { try { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } // Validate system if (!['drs', 'flashcards'].includes(system)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid system' })); return; } const filename = system === 'drs' ? `${system}-progress-${bookId}-${chapterId}.json` : `${system}-progress.json`; const filePath = join(__dirname, 'saves', filename); try { const content = await readFile(filePath, 'utf8'); const progressData = JSON.parse(content); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(progressData)); console.log(` ✅ Loaded ${system} progress: ${filename}`); } catch (fileError) { // File doesn't exist - return empty progress const emptyProgress = system === 'drs' ? { masteredVocabulary: [], masteredPhrases: [], masteredGrammar: [], completed: false, masteryCount: 0, system, bookId, chapterId } : { system: 'flashcards', progress: {}, stats: {} }; res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(emptyProgress)); console.log(` ℹ️ No saved progress found for ${system}, returning empty`); } } catch (error) { console.error('Error in progress load API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to load progress' })); } } // API handler for LLM configuration async function handleLLMConfigAPI(req, res) { try { // Only allow GET requests if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } // Load environment variables const { config } = await import('dotenv'); config(); // Extract only the LLM API keys const llmConfig = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, MISTRAL_API_KEY: process.env.MISTRAL_API_KEY, GEMINI_API_KEY: process.env.GEMINI_API_KEY }; // Filter out undefined keys const validKeys = Object.fromEntries( Object.entries(llmConfig).filter(([key, value]) => value && value.length > 0) ); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(validKeys)); console.log(` ✅ Served LLM config with ${Object.keys(validKeys).length} API keys`); } catch (error) { console.error('Error in LLM config API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to load LLM configuration' })); } } // Data merge handler for combining local and external progress async function handleProgressMerge(req, res) { try { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } // Read request body let body = ''; req.on('data', chunk => body += chunk.toString()); req.on('end', async () => { try { const { system, bookId, chapterId, localData, externalData, mergeStrategy = 'timestamp' } = JSON.parse(body); // Validate system if (!['drs', 'flashcards'].includes(system)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid system. Only "drs" and "flashcards" allowed' })); return; } // Perform data merge const mergedData = await mergeProgressData(localData, externalData, mergeStrategy); // Add merge metadata mergedData.mergeInfo = { strategy: mergeStrategy, mergedAt: new Date().toISOString(), localItems: countProgressItems(localData), externalItems: countProgressItems(externalData), totalItems: countProgressItems(mergedData), conflicts: mergedData.conflicts || [] }; // Save merged data const savesDir = join(__dirname, 'saves'); if (!existsSync(savesDir)) { await mkdir(savesDir, { recursive: true }); } const filename = system === 'drs' ? `${system}-progress-${bookId}-${chapterId}.json` : `${system}-progress.json`; const filePath = join(savesDir, filename); await writeFile(filePath, JSON.stringify(mergedData, null, 2)); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify({ success: true, mergedData, mergeInfo: mergedData.mergeInfo })); console.log(` ✅ Merged ${system} progress: ${mergedData.mergeInfo.totalItems} total items`); } catch (parseError) { console.error('Error parsing progress merge request:', parseError); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON data' })); } }); } catch (error) { console.error('Error in progress merge API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to merge progress' })); } } // Sync status handler async function handleSyncStatus(req, res) { try { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } const savesDir = join(__dirname, 'saves'); const syncStatus = { savesDirectory: savesDir, lastSync: null, savedFiles: [], totalFiles: 0 }; try { if (existsSync(savesDir)) { const files = await readdir(savesDir); const jsonFiles = files.filter(file => file.endsWith('.json')); syncStatus.totalFiles = jsonFiles.length; for (const file of jsonFiles) { try { const filePath = join(savesDir, file); const stats = await stat(filePath); const content = await readFile(filePath, 'utf8'); const data = JSON.parse(content); syncStatus.savedFiles.push({ filename: file, lastModified: stats.mtime.toISOString(), savedAt: data.savedAt || stats.mtime.toISOString(), system: data.system || 'unknown', bookId: data.bookId, chapterId: data.chapterId, hasTimestamps: hasTimestampData(data), itemCount: countProgressItems(data) }); // Update last sync time to most recent file if (!syncStatus.lastSync || stats.mtime > new Date(syncStatus.lastSync)) { syncStatus.lastSync = stats.mtime.toISOString(); } } catch (fileError) { console.warn(`Error reading file ${file}:`, fileError); } } } } catch (dirError) { console.warn('Saves directory not accessible:', dirError); } res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.writeHead(200); res.end(JSON.stringify(syncStatus)); console.log(` ✅ Sync status: ${syncStatus.totalFiles} files`); } catch (error) { console.error('Error in sync status API:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to get sync status' })); } } // Data merge utilities async function mergeProgressData(localData, externalData, strategy = 'timestamp') { const merged = { masteredVocabulary: [], masteredPhrases: [], masteredGrammar: [], completed: false, masteryCount: 0, conflicts: [] }; // Copy metadata from most recent source const localTimestamp = localData?.savedAt || localData?.lastModified || '1970-01-01T00:00:00.000Z'; const externalTimestamp = externalData?.savedAt || externalData?.lastModified || '1970-01-01T00:00:00.000Z'; const useExternal = new Date(externalTimestamp) > new Date(localTimestamp); const primarySource = useExternal ? externalData : localData; const secondarySource = useExternal ? localData : externalData; // Copy basic properties merged.system = primarySource.system || localData.system || 'drs'; merged.bookId = primarySource.bookId || localData.bookId; merged.chapterId = primarySource.chapterId || localData.chapterId; merged.completed = primarySource.completed || secondarySource.completed || false; merged.savedAt = new Date().toISOString(); // Merge each category const categories = ['masteredVocabulary', 'masteredPhrases', 'masteredGrammar']; for (const category of categories) { const localItems = localData[category] || []; const externalItems = externalData[category] || []; const mergeResult = mergeItemArrays(localItems, externalItems, strategy); merged[category] = mergeResult.items; merged.conflicts.push(...mergeResult.conflicts.map(c => ({ ...c, category }))); } // Calculate mastery count (max of both sources plus any new merged items) merged.masteryCount = Math.max( localData.masteryCount || 0, externalData.masteryCount || 0, merged.masteredVocabulary.length + merged.masteredPhrases.length + merged.masteredGrammar.length ); return merged; } function mergeItemArrays(localItems, externalItems, strategy) { const result = { items: [], conflicts: [] }; const itemMap = new Map(); // Add all items to map, handling conflicts const addToMap = (items, source) => { items.forEach(item => { const itemKey = typeof item === 'string' ? item : item.item; const itemData = typeof item === 'string' ? { item, masteredAt: '1970-01-01T00:00:00.000Z', source } : { ...item, source }; if (itemMap.has(itemKey)) { const existing = itemMap.get(itemKey); const conflict = { item: itemKey, local: source === 'local' ? itemData : existing, external: source === 'external' ? itemData : existing, resolution: 'pending' }; // Resolve conflict based on strategy let resolvedItem; switch (strategy) { case 'timestamp': const existingTime = new Date(existing.masteredAt || existing.lastReviewAt || '1970-01-01'); const newTime = new Date(itemData.masteredAt || itemData.lastReviewAt || '1970-01-01'); if (newTime > existingTime) { resolvedItem = { ...itemData, attempts: (existing.attempts || 1) + (itemData.attempts || 1) }; conflict.resolution = `newer_timestamp_from_${source}`; } else { resolvedItem = { ...existing, attempts: (existing.attempts || 1) + (itemData.attempts || 1) }; conflict.resolution = 'kept_existing_newer_timestamp'; } break; case 'attempts': resolvedItem = { ...existing, attempts: (existing.attempts || 1) + (itemData.attempts || 1), lastReviewAt: new Date().toISOString() }; conflict.resolution = 'merged_attempts'; break; case 'prefer_local': resolvedItem = source === 'local' ? itemData : existing; conflict.resolution = 'preferred_local'; break; case 'prefer_external': resolvedItem = source === 'external' ? itemData : existing; conflict.resolution = 'preferred_external'; break; default: resolvedItem = existing; conflict.resolution = 'kept_existing_default'; } itemMap.set(itemKey, resolvedItem); result.conflicts.push(conflict); } else { itemMap.set(itemKey, itemData); } }); }; // Process both arrays addToMap(localItems, 'local'); addToMap(externalItems, 'external'); // Convert map back to array, removing source metadata result.items = Array.from(itemMap.values()).map(item => { const { source, ...cleanItem } = item; return cleanItem; }); return result; } function countProgressItems(data) { if (!data) return 0; const vocab = data.masteredVocabulary?.length || 0; const phrases = data.masteredPhrases?.length || 0; const grammar = data.masteredGrammar?.length || 0; return vocab + phrases + grammar; } function hasTimestampData(data) { if (!data) return false; const checkArray = (arr) => { return arr && arr.length > 0 && arr.some(item => typeof item === 'object' && (item.masteredAt || item.lastReviewAt) ); }; return checkArray(data.masteredVocabulary) || checkArray(data.masteredPhrases) || checkArray(data.masteredGrammar); } // Start server server.listen(PORT, HOST, () => { console.log('\n🚀 Class Generator Development Server'); console.log('====================================='); console.log(`📍 Local: http://${HOST}:${PORT}/`); console.log(`🌐 Network: http://localhost:${PORT}/`); console.log('📁 Serving files from:', __dirname); console.log('\n✨ Features:'); console.log(' • ES6 modules support'); console.log(' • CORS enabled'); console.log(' • Static file serving'); console.log(' • Development-friendly caching'); console.log('\n🔥 Ready for development!'); console.log('Press Ctrl+C to stop\n'); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\n👋 Shutting down server...'); server.close(() => { console.log('✅ Server stopped'); process.exit(0); }); }); process.on('SIGTERM', () => { console.log('\n👋 Received SIGTERM, shutting down...'); server.close(() => { console.log('✅ Server stopped'); process.exit(0); }); });