- Add AIReportSystem.js for detailed AI response capture and report generation - Add AIReportInterface.js UI component for report access and export - Integrate AI reporting into LLMValidator and SmartPreviewOrchestrator - Add missing modules to Application.js configuration (unifiedDRS, smartPreviewOrchestrator) - Create missing content/chapters/sbs.json for book metadata - Enhance Application.js with debug logging for module loading - Add multi-format export capabilities (text, HTML, JSON) - Implement automatic learning insights extraction from AI feedback - Add session management and performance tracking for AI reports 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
807 lines
29 KiB
JavaScript
807 lines
29 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>404 - Not Found</title>
|
||
<style>
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
text-align: center;
|
||
padding: 2rem;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
}
|
||
h1 { font-size: 3rem; margin-bottom: 1rem; }
|
||
p { font-size: 1.2rem; opacity: 0.8; }
|
||
a { color: white; text-decoration: underline; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>404</h1>
|
||
<p>${message}</p>
|
||
<p><a href="/">← Back to Class Generator</a></p>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
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);
|
||
});
|
||
}); |