// === SCANNER AUTOMATIQUE DE CONTENU === class ContentScanner { constructor() { this.discoveredContent = new Map(); this.contentDirectory = 'js/content/'; // Configuration depuis EnvConfig this.envConfig = window.envConfig; logSh('🔧 ContentScanner configuré avec:', 'INFO'); } async scanAllContent() { logSh('🔍 ContentScanner - Scan automatique du dossier content...', 'INFO'); const results = { found: [], errors: [], total: 0 }; try { // Découvrir tous les fichiers .js dans le dossier content const contentFiles = await this.discoverContentFiles(); logSh(`📁 Fichiers trouvés: ${contentFiles.join(', ')}`, 'INFO'); for (const filename of contentFiles) { try { const contentInfo = await this.scanContentFile(filename); if (contentInfo) { this.discoveredContent.set(contentInfo.id, contentInfo); results.found.push(contentInfo); } } catch (error) { logSh(`⚠️ Erreur scan ${filename}:`, error.message, 'WARN'); results.errors.push({ filename, error: error.message }); } } } catch (error) { logSh('❌ Erreur lors de la découverte des fichiers:', error.message, 'ERROR'); results.errors.push({ error: `Scan directory failed: ${error.message}` }); } results.total = results.found.length; logSh(`✅ Scan terminé: ${results.total} modules trouvés`, 'INFO'); return results; } async discoverContentFiles() { // Détecter si on est en mode file:// ou serveur web const isFileProtocol = window.location.protocol === 'file:'; if (isFileProtocol) { logSh('📂 Mode fichier local - chargement des fichiers connus', 'INFO'); // D'abord essayer de charger les fichiers connus await this.preloadKnownFiles(); // Puis scanner les modules chargés return this.scanLoadedModules(); } // Méthode 1: Essayer de récupérer le listing via fetch (si serveur web supporte) try { const response = await fetch(this.contentDirectory); if (response.ok) { const html = await response.text(); // Parser les liens .js dans le HTML du directory listing const jsFiles = this.parseDirectoryListing(html); if (jsFiles.length > 0) { logSh('📂 Méthode directory listing réussie', 'INFO'); return jsFiles; } } } catch (error) { logSh('📂 Directory listing failed, trying known files', 'INFO'); } // Méthode 2: Essayer une liste de fichiers communs logSh('📂 Utilisation de la liste de test', 'INFO'); return await this.tryCommonFiles(); } async preloadKnownFiles() { const knownFiles = [ 'sbs-level-7-8-new.json', // Format JSON 'basic-chinese.js', 'english-class-demo.json', // Format JSON 'test-animals.js' ]; logSh('📂 Préchargement des fichiers connus...', 'INFO'); this.updateConnectionStatus('loading'); let remoteSuccess = 0; let localSuccess = 0; for (const filename of knownFiles) { try { if (filename.endsWith('.json')) { // Essayer d'abord le contenu distant, puis local en fallback const success = await this.loadJsonWithFallback(filename); if (success === 'remote') remoteSuccess++; else if (success === 'local') localSuccess++; } else { // Fichiers JS en local uniquement await this.loadScript(`${this.contentDirectory}${filename}`); localSuccess++; } logSh(`✓ Chargé: ${filename}`, 'INFO'); } catch (error) { logSh(`⚠️ Ignoré: ${filename} (${error.message})`, 'INFO'); } } // Mise à jour du statut de connexion if (remoteSuccess > 0) { this.updateConnectionStatus('online', `${remoteSuccess} contenus distants`); } else if (localSuccess > 0) { this.updateConnectionStatus('offline', `${localSuccess} contenus locaux`); } else { this.updateConnectionStatus('error', 'Aucun contenu chargé'); } } async loadJsonWithFallback(filename) { const tryRemoteFirst = this.envConfig?.get('TRY_REMOTE_FIRST') && this.shouldTryRemote(); if (tryRemoteFirst) { // Mode distant prioritaire (rare) const remoteResult = await this.tryRemoteLoad(filename); if (remoteResult.success) return remoteResult.source; // Fallback local const localResult = await this.tryLocalLoad(filename); if (localResult.success) return localResult.source; throw new Error(`Impossible de charger ${filename}: Remote (${remoteResult.error}) et Local (${localResult.error})`); } else { // Mode LOCAL PRIORITAIRE (par défaut) const localResult = await this.tryLocalLoad(filename); if (localResult.success) return localResult.source; // Fallback distant seulement si configuré et réseau disponible if (this.shouldTryRemote()) { const remoteResult = await this.tryRemoteLoad(filename); if (remoteResult.success) return remoteResult.source; throw new Error(`Impossible de charger ${filename}: Local (${localResult.error}) et Remote (${remoteResult.error})`); } else { throw new Error(`Impossible de charger ${filename}: ${localResult.error} (distant désactivé)`); } } } async tryLocalLoad(filename) { try { const localUrl = `${this.contentDirectory}${filename}`; await this.loadJsonContent(localUrl); logSh(`💾 Chargé depuis local: ${filename}`, 'INFO'); return { success: true, source: 'local' }; } catch (error) { logSh(`💾 Local échoué pour ${filename}: ${error.message}`, 'INFO'); return { success: false, error: error.message }; } } async tryRemoteLoad(filename) { try { const remoteUrl = `${this.envConfig.getRemoteContentUrl()}${filename}`; // Fetch avec timeout court const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.envConfig.get('REMOTE_TIMEOUT')); await this.loadJsonContentWithTimeout(remoteUrl, controller.signal); clearTimeout(timeoutId); logSh(`🌐 Chargé depuis distant: ${filename}`, 'INFO'); return { success: true, source: 'remote' }; } catch (error) { logSh(`🌐 Distant échoué pour ${filename}: ${error.message}`, 'INFO'); return { success: false, error: error.message }; } } async loadJsonContentWithTimeout(url, signal) { const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const jsonData = await response.json(); const moduleName = this.jsonFilenameToModuleName(url); window.ContentModules = window.ContentModules || {}; window.ContentModules[moduleName] = jsonData; } shouldTryRemote() { // Utiliser la configuration de EnvConfig return this.envConfig?.isRemoteContentEnabled() && (window.location.protocol !== 'file:' || this.envConfig.get('FORCE_REMOTE_ON_FILE')); } updateConnectionStatus(status, details = '') { // Émettre un événement personnalisé pour l'UI const event = new CustomEvent('contentConnectionStatus', { detail: { status, details, timestamp: new Date() } }); window.dispatchEvent(event); } scanLoadedModules() { // Scanner les modules déjà présents dans window.ContentModules const loadedFiles = []; if (window.ContentModules) { for (const moduleName in window.ContentModules) { // Convertir le nom du module en nom de fichier probable const filename = this.moduleNameToFilename(moduleName); loadedFiles.push(filename); logSh(`✓ Module découvert: ${moduleName} → ${filename}`, 'INFO'); } } return loadedFiles; } moduleNameToFilename(moduleName) { // Mapping des noms de modules vers les noms de fichiers const mapping = { 'SBSLevel78New': 'sbs-level-7-8-new.json', 'BasicChinese': 'basic-chinese.js', 'EnglishClassDemo': 'english-class-demo.json', 'TestAnimals': 'test-animals.js' }; if (mapping[moduleName]) { return mapping[moduleName]; } // Conversion générique PascalCase → kebab-case return moduleName .replace(/([a-z])([A-Z])/g, '$1-$2') .toLowerCase() + '.js'; } parseDirectoryListing(html) { const jsFiles = []; // Regex pour trouver les liens vers des fichiers .js const linkRegex = /]+href="([^"]+\.js)"[^>]*>/gi; let match; while ((match = linkRegex.exec(html)) !== null) { const filename = match[1]; // Éviter les fichiers système ou temporaires if (!filename.startsWith('.') && !filename.includes('test') && !filename.includes('backup')) { jsFiles.push(filename); } } return jsFiles; } async tryCommonFiles() { // Liste des fichiers à tester (sera étendue dynamiquement) const possibleFiles = [ 'sbs-level-7-8-new.js', 'sbs-level-7-8-new.json', 'basic-chinese.js', 'sbs-level-8.js', 'animals.js', 'colors.js', 'family.js', 'food.js', 'house.js', 'english-basic.js', 'french-basic.js', 'spanish-basic.js', 'english-class-demo.json', 'test-animals.js' ]; const existingFiles = []; for (const filename of possibleFiles) { try { // Tester local d'abord const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); const response = await fetch(`${this.contentDirectory}${filename}`, { method: 'HEAD', signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { existingFiles.push(filename); logSh(`✓ Trouvé (local): ${filename}`, 'INFO'); continue; } } catch (error) { // Fichier local n'existe pas } // Si pas trouvé en local et remote activé, tester remote if (this.shouldTryRemoteContent()) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const remoteUrl = `${this.envConfig.getRemoteContentUrl()}${filename}`; const authHeaders = await this.envConfig.getAuthHeaders('HEAD', remoteUrl); const response = await fetch(remoteUrl, { method: 'HEAD', signal: controller.signal, headers: authHeaders }); clearTimeout(timeoutId); if (response.ok) { existingFiles.push(filename); logSh(`✓ Trouvé (remote): ${filename}`, 'INFO'); } else if (response.status === 403) { logSh(`🔒 Trouvé mais privé (remote): ${filename}`, 'INFO'); // Même si privé, on considère que le fichier existe existingFiles.push(filename); } } catch (error) { // Fichier remote n'existe pas ou timeout } } } return existingFiles; } async scanContentFile(filename) { const contentId = this.extractContentId(filename); const moduleName = this.getModuleName(contentId); try { // Charger le script si pas déjà fait await this.loadScript(`js/content/${filename}`); // Vérifier si le module existe if (!window.ContentModules || !window.ContentModules[moduleName]) { throw new Error(`Module ${moduleName} non trouvé après chargement`); } const module = window.ContentModules[moduleName]; // Extraire les métadonnées const contentInfo = this.extractContentInfo(module, contentId, filename); logSh(`📦 Contenu découvert: ${contentInfo.name}`, 'INFO'); return contentInfo; } catch (error) { throw new Error(`Impossible de charger ${filename}: ${error.message}`); } } extractContentInfo(module, contentId, filename) { return { id: contentId, filename: filename, name: module.name || this.beautifyContentId(contentId), description: module.description || 'Contenu automatiquement détecté', icon: this.getContentIcon(module, contentId), difficulty: module.difficulty || 'medium', enabled: true, // Métadonnées détaillées metadata: { version: module.version || '1.0', format: module.format || 'legacy', totalItems: this.countItems(module), categories: this.extractCategories(module), contentTypes: this.extractContentTypes(module), estimatedTime: this.calculateEstimatedTime(module), lastScanned: new Date().toISOString() }, // Statistiques stats: { vocabularyCount: this.countByType(module, 'vocabulary'), sentenceCount: this.countByType(module, 'sentence'), dialogueCount: this.countByType(module, 'dialogue'), grammarCount: this.countByType(module, 'grammar') }, // Configuration pour les jeux gameCompatibility: this.analyzeGameCompatibility(module) }; } extractContentId(filename) { return filename.replace('.js', '').toLowerCase(); } getModuleName(contentId) { const mapping = { 'sbs-level-7-8-new': 'SBSLevel78New' }; return mapping[contentId] || this.toPascalCase(contentId); } toPascalCase(str) { return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1) ).join(''); } beautifyContentId(contentId) { const beautified = { 'sbs-level-7-8-new': 'SBS Level 7-8 (Simple Format)' }; return beautified[contentId] || contentId.charAt(0).toUpperCase() + contentId.slice(1); } getContentIcon(module, contentId) { // Icône du module si disponible if (module.icon) return module.icon; // Icônes par défaut selon l'ID const defaultIcons = { 'sbs-level-7-8-new': '✨' }; return defaultIcons[contentId] || '📝'; } countItems(module) { let count = 0; // Format moderne (contentItems) if (module.contentItems && Array.isArray(module.contentItems)) { return module.contentItems.length; } // Format simple (vocabulary object + sentences array) if (module.vocabulary && typeof module.vocabulary === 'object' && !Array.isArray(module.vocabulary)) { count += Object.keys(module.vocabulary).length; } // Format legacy (vocabulary array) else if (module.vocabulary && Array.isArray(module.vocabulary)) { count += module.vocabulary.length; } // Autres contenus if (module.sentences && Array.isArray(module.sentences)) count += module.sentences.length; if (module.dialogues && Array.isArray(module.dialogues)) count += module.dialogues.length; if (module.phrases && Array.isArray(module.phrases)) count += module.phrases.length; if (module.texts && Array.isArray(module.texts)) count += module.texts.length; return count; } extractCategories(module) { const categories = new Set(); if (module.categories) { Object.keys(module.categories).forEach(cat => categories.add(cat)); } if (module.metadata && module.metadata.categories) { module.metadata.categories.forEach(cat => categories.add(cat)); } // Extraire des contenus si format moderne if (module.contentItems) { module.contentItems.forEach(item => { if (item.category) categories.add(item.category); }); } // Extraire du vocabulaire selon le format if (module.vocabulary) { // Format simple (vocabulary object) if (typeof module.vocabulary === 'object' && !Array.isArray(module.vocabulary)) { // Pour l'instant, pas de catégories dans le format simple categories.add('vocabulary'); } // Format legacy (vocabulary array) else if (Array.isArray(module.vocabulary)) { module.vocabulary.forEach(word => { if (word.category) categories.add(word.category); }); } } return Array.from(categories); } extractContentTypes(module) { const types = new Set(); if (module.contentItems) { module.contentItems.forEach(item => { if (item.type) types.add(item.type); }); } else { // Format legacy - deviner les types if (module.vocabulary) types.add('vocabulary'); if (module.sentences) types.add('sentence'); if (module.dialogues || module.dialogue) types.add('dialogue'); if (module.phrases) types.add('sentence'); } return Array.from(types); } calculateEstimatedTime(module) { if (module.metadata && module.metadata.estimatedTime) { return module.metadata.estimatedTime; } // Calcul basique : 1 minute par 3 éléments const itemCount = this.countItems(module); return Math.max(5, Math.ceil(itemCount / 3)); } countByType(module, type) { if (module.contentItems) { return module.contentItems.filter(item => item.type === type).length; } // Format simple et legacy switch(type) { case 'vocabulary': // Format simple (vocabulary object) if (module.vocabulary && typeof module.vocabulary === 'object' && !Array.isArray(module.vocabulary)) { return Object.keys(module.vocabulary).length; } // Format legacy (vocabulary array) return module.vocabulary ? module.vocabulary.length : 0; case 'sentence': return (module.sentences ? module.sentences.length : 0) + (module.phrases ? module.phrases.length : 0); case 'dialogue': return module.dialogues ? module.dialogues.length : 0; case 'grammar': // Format simple (grammar object avec sous-propriétés) if (module.grammar && typeof module.grammar === 'object') { return Object.keys(module.grammar).length; } return module.grammar && Array.isArray(module.grammar) ? module.grammar.length : 0; default: return 0; } } analyzeGameCompatibility(module) { const compatibility = { 'whack-a-mole': { compatible: false, score: 0 }, 'memory-game': { compatible: false, score: 0 }, 'story-builder': { compatible: false, score: 0 }, }; const vocabCount = this.countByType(module, 'vocabulary'); const sentenceCount = this.countByType(module, 'sentence'); const dialogueCount = this.countByType(module, 'dialogue'); // Whack-a-Mole - aime le vocabulaire et phrases simples if (vocabCount > 5 || sentenceCount > 3) { compatibility['whack-a-mole'].compatible = true; compatibility['whack-a-mole'].score = Math.min(100, vocabCount * 5 + sentenceCount * 3); } // Memory Game - parfait pour vocabulaire avec images if (vocabCount > 4) { compatibility['memory-game'].compatible = true; compatibility['memory-game'].score = Math.min(100, vocabCount * 8); } // Story Builder - aime les dialogues et séquences if (dialogueCount > 0 || sentenceCount > 5) { compatibility['story-builder'].compatible = true; compatibility['story-builder'].score = Math.min(100, dialogueCount * 15 + sentenceCount * 2); } return compatibility; } async loadJsonContent(src) { try { const response = await fetch(src); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const jsonData = await response.json(); // Créer le module automatiquement const moduleName = this.jsonFilenameToModuleName(src); window.ContentModules = window.ContentModules || {}; window.ContentModules[moduleName] = jsonData; logSh(`📋 Module JSON créé: ${moduleName}`, 'INFO'); } catch (error) { throw new Error(`Impossible de charger JSON ${src}: ${error.message}`); } } jsonFilenameToModuleName(src) { // Extraire le nom du fichier et le convertir en PascalCase const filename = src.split('/').pop().replace('.json', ''); return this.toPascalCase(filename); } async loadScript(src) { return new Promise((resolve, reject) => { // Vérifier si déjà chargé const existingScript = document.querySelector(`script[src="${src}"]`); if (existingScript) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = () => reject(new Error(`Impossible de charger ${src}`)); document.head.appendChild(script); }); } // === API PUBLIQUE === async getAvailableContent() { if (this.discoveredContent.size === 0) { await this.scanAllContent(); } return Array.from(this.discoveredContent.values()); } async getContentById(id) { if (this.discoveredContent.size === 0) { await this.scanAllContent(); } return this.discoveredContent.get(id); } async getContentByGame(gameType) { const allContent = await this.getAvailableContent(); return allContent.filter(content => { const compat = content.gameCompatibility[gameType]; return compat && compat.compatible; }).sort((a, b) => { // Trier par score de compatibilité const scoreA = a.gameCompatibility[gameType].score; const scoreB = b.gameCompatibility[gameType].score; return scoreB - scoreA; }); } async refreshContent() { this.discoveredContent.clear(); return await this.scanAllContent(); } getContentStats() { const stats = { totalModules: this.discoveredContent.size, totalItems: 0, categories: new Set(), contentTypes: new Set(), difficulties: new Set() }; for (const content of this.discoveredContent.values()) { stats.totalItems += content.metadata.totalItems; content.metadata.categories.forEach(cat => stats.categories.add(cat)); content.metadata.contentTypes.forEach(type => stats.contentTypes.add(type)); stats.difficulties.add(content.difficulty); } return { ...stats, categories: Array.from(stats.categories), contentTypes: Array.from(stats.contentTypes), difficulties: Array.from(stats.difficulties) }; } } // Export global window.ContentScanner = ContentScanner;