Class_generator/js/core/content-scanner.js
StillHammer 1f8688c4aa Fix WebSocket logging system and add comprehensive network features
- Fix WebSocket server to properly broadcast logs to all connected clients
- Integrate professional logging system with real-time WebSocket interface
- Add network status indicator with DigitalOcean Spaces connectivity
- Implement AWS Signature V4 authentication for private bucket access
- Add JSON content loader with backward compatibility to JS modules
- Restore navigation breadcrumb system with comprehensive logging
- Add multiple content formats: JSON + JS with automatic discovery
- Enhance top bar with logger toggle and network status indicator
- Remove deprecated temp-games module and clean up unused files

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 23:05:14 +08:00

699 lines
25 KiB
JavaScript

// === 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 = /<a[^>]+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;