Major Changes: - Moved legacy system to Legacy/ folder for archival - Built new modular architecture with strict separation of concerns - Created core system: Module, EventBus, ModuleLoader, Router - Added Application bootstrap with auto-start functionality - Implemented development server with ES6 modules support - Created comprehensive documentation and project context - Converted SBS-7-8 content to JSON format - Copied all legacy games and content to new structure New Architecture Features: - Sealed modules with WeakMap private data - Strict dependency injection system - Event-driven communication only - Inviolable responsibility patterns - Auto-initialization without commands - Component-based UI foundation ready Technical Stack: - Vanilla JS/HTML/CSS only - ES6 modules with proper imports/exports - HTTP development server (no file:// protocol) - Modular CSS with component scoping - Comprehensive error handling and debugging Ready for Phase 2: Converting legacy modules to new architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1155 lines
45 KiB
JavaScript
1155 lines
45 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 {
|
|
logSh(`🔍 Scanning content file: ${filename}`, 'DEBUG');
|
|
const contentInfo = await this.scanContentFile(filename);
|
|
if (contentInfo) {
|
|
logSh(`✅ Successfully scanned: ${contentInfo.id} (${contentInfo.name})`, 'INFO');
|
|
this.discoveredContent.set(contentInfo.id, contentInfo);
|
|
results.found.push(contentInfo);
|
|
} else {
|
|
logSh(`⚠️ scanContentFile returned null for ${filename}`, 'WARN');
|
|
}
|
|
} 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;
|
|
// Analyser les capacités globales du contenu découvert
|
|
this.analyzeGlobalCapabilities(results.found);
|
|
|
|
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();
|
|
}
|
|
|
|
let allFiles = [];
|
|
|
|
// Méthode 1: Récupérer le listing local
|
|
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');
|
|
allFiles = [...jsFiles];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logSh('📂 Directory listing failed', 'INFO');
|
|
}
|
|
|
|
// Méthode 2: Toujours essayer les fichiers distants connus
|
|
logSh('📂 Tentative de scan des fichiers distants...', 'INFO');
|
|
const commonFiles = await this.tryCommonFiles();
|
|
|
|
// Combiner les résultats locaux et distants sans doublons
|
|
for (const file of commonFiles) {
|
|
if (!allFiles.includes(file)) {
|
|
allFiles.push(file);
|
|
}
|
|
}
|
|
|
|
logSh(`📁 Fichiers trouvés: ${allFiles.join(', ')}`, 'INFO');
|
|
return allFiles;
|
|
}
|
|
|
|
async preloadKnownFiles() {
|
|
const knownFiles = [
|
|
'sbs-level-7-8-new.js', // Local JS file
|
|
'SBS-level-1.js', // Side by Side Level 1 - English introduction with Chinese translation
|
|
'english-class-demo.json', // Remote JSON file
|
|
'english-exemple-commented.js', // Module JS complet nouvellement créé
|
|
'story-test.js', // Story test module
|
|
'story-prototype-1000words.js', // 1000-word story prototype
|
|
'story-complete-1000words.js', // Complete 1000-word story with pronunciation
|
|
'chinese-long-story.js', // Chinese story with English translation and pinyin
|
|
'french-beginner-story.js', // French beginner story for English speakers
|
|
'WTA1B1.js', // English letters and pets story with Chinese translation
|
|
'story-prototype-optimized.js', // Optimized story with centralized vocabulary
|
|
'test-compatibility.js', // Test content for compatibility system
|
|
'test-minimal.js', // Minimal test content
|
|
'test-rich.js', // Rich test content
|
|
'NCE1-Lesson63-64.js', // New Concept English Book 1 - Lessons 63-64
|
|
'NCE2-Lesson3.js', // New Concept English Book 2 - Lesson 3
|
|
'NCE2-Lesson30.js' // New Concept English Book 2 - Lesson 30
|
|
];
|
|
|
|
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}`;
|
|
const response = await fetch(localUrl);
|
|
if (response.ok) {
|
|
const jsonData = await response.json();
|
|
const moduleName = this.jsonFilenameToModuleName(filename);
|
|
window.ContentModules = window.ContentModules || {};
|
|
window.ContentModules[moduleName] = jsonData;
|
|
logSh(`💾 Chargé depuis local: ${filename}`, 'INFO');
|
|
return { success: true, source: 'local' };
|
|
} else {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
logSh(`💾 Local échoué pour ${filename}: ${error.message}`, 'INFO');
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
async tryRemoteLoad(filename) {
|
|
// Double vérification: ne pas essayer en mode file://
|
|
if (window.location.protocol === 'file:') {
|
|
logSh(`📁 Mode file:// - Saut du chargement distant pour ${filename}`, 'DEBUG');
|
|
return { success: false, error: 'Mode file:// - distant désactivé' };
|
|
}
|
|
|
|
try {
|
|
// UTILISER LE PROXY LOCAL au lieu de DigitalOcean directement
|
|
const remoteUrl = `http://localhost:8083/do-proxy/${filename}`;
|
|
|
|
logSh(`🔍 Tentative de chargement distant via proxy: ${remoteUrl}`, 'INFO');
|
|
logSh(`🔧 Protocol: ${window.location.protocol}, Remote enabled: ${this.envConfig?.isRemoteContentEnabled()}`, 'DEBUG');
|
|
|
|
// 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}`, 'ERROR');
|
|
logSh(`🔍 Stack trace: ${error.stack}`, 'DEBUG');
|
|
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) {
|
|
logSh(`📂 Modules already loaded in window.ContentModules:`, 'DEBUG');
|
|
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');
|
|
|
|
// Debug : vérifier si le module a un id
|
|
const module = window.ContentModules[moduleName];
|
|
if (module.id) {
|
|
logSh(` Module ID: ${module.id}`, 'DEBUG');
|
|
} else {
|
|
logSh(` ⚠️ Module ${moduleName} has no ID!`, 'WARN');
|
|
}
|
|
}
|
|
} else {
|
|
logSh(`⚠️ window.ContentModules is undefined!`, 'WARN');
|
|
}
|
|
|
|
logSh(`📂 Total loaded files from modules: ${loadedFiles.length}`, 'INFO');
|
|
return loadedFiles;
|
|
}
|
|
|
|
moduleNameToFilename(moduleName) {
|
|
// Mapping des noms de modules vers les noms de fichiers
|
|
const mapping = {
|
|
'SBSLevel78New': 'sbs-level-7-8-new.js',
|
|
'EnglishClassDemo': 'english-class-demo.json',
|
|
'EnglishExemple': 'english_exemple.json',
|
|
'EnglishExempleFixed': 'english_exemple_fixed.json',
|
|
'EnglishExempleUltraCommented': 'english_exemple_ultra_commented.json',
|
|
// AJOUT: Fichiers générés par le système de conversion
|
|
'SbsLevel78GeneratedFromJs': 'sbs-level-7-8-GENERATED-from-js.json',
|
|
'EnglishExempleCommentedGenerated': 'english-exemple-commented-GENERATED.json',
|
|
// AJOUT: Module JS complet nouvellement créé
|
|
'EnglishExempleCommented': 'english-exemple-commented.js',
|
|
// AJOUT: Story modules
|
|
'StoryTest': 'story-test.js',
|
|
'StoryPrototype1000words': 'story-prototype-1000words.js',
|
|
'StoryComplete1000words': 'story-complete-1000words.js',
|
|
'ChineseLongStory': 'chinese-long-story.js',
|
|
'FrenchBeginnerStory': 'french-beginner-story.js',
|
|
'WTA1B1': 'WTA1B1.js',
|
|
'SBSLevel1': 'SBS-level-1.js',
|
|
'StoryPrototypeOptimized': 'story-prototype-optimized.js',
|
|
// AJOUT: Test compatibility modules
|
|
'TestMinimalContent': 'test-compatibility.js',
|
|
'TestRichContent': 'test-compatibility.js',
|
|
'TestSentenceOnly': 'test-compatibility.js',
|
|
// AJOUT: NCE modules
|
|
'NCE1Lesson6364': 'NCE1-Lesson63-64.js',
|
|
'NCE2Lesson3': 'NCE2-Lesson3.js',
|
|
'NCE2Lesson30': 'NCE2-Lesson30.js'
|
|
};
|
|
|
|
if (mapping[moduleName]) {
|
|
return mapping[moduleName];
|
|
}
|
|
|
|
// Conversion générique PascalCase → kebab-case
|
|
// Essayer d'abord .json puis .js
|
|
const baseName = moduleName
|
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
.toLowerCase();
|
|
return baseName + '.json'; // Préférer JSON en premier
|
|
}
|
|
|
|
parseDirectoryListing(html) {
|
|
const contentFiles = [];
|
|
// Regex pour trouver les liens vers des fichiers .js ET .json
|
|
const linkRegex = /<a[^>]+href="([^"]+\.(js|json))"[^>]*>/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')) {
|
|
contentFiles.push(filename);
|
|
}
|
|
}
|
|
|
|
return contentFiles;
|
|
}
|
|
|
|
async tryCommonFiles() {
|
|
// FIXME: Liste temporaire - À remplacer par listing dynamique DigitalOcean
|
|
// Pour l'instant on met juste les fichiers qu'on sait qui existent
|
|
const possibleFiles = [
|
|
'sbs-level-7-8-new.js', // Local JS legacy
|
|
'english-class-demo.json', // Remote JSON example
|
|
'english_exemple.json', // Local JSON basic
|
|
'english_exemple_fixed.json', // Local JSON modular
|
|
'english_exemple_ultra_commented.json', // Local JSON ultra-modular
|
|
'example-with-images.js', // Local JS with image support for Word Discovery
|
|
// AJOUT: Fichiers générés par le système de conversion
|
|
'sbs-level-7-8-GENERATED-from-js.json',
|
|
'english-exemple-commented-GENERATED.json',
|
|
// AJOUT: Fichiers NCE (New Concept English)
|
|
'NCE1-Lesson63-64.js', // New Concept English Book 1 - Lessons 63-64
|
|
'NCE2-Lesson3.js', // New Concept English Book 2 - Lesson 3
|
|
'NCE2-Lesson30.js' // New Concept English Book 2 - Lesson 30
|
|
];
|
|
|
|
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 via proxy
|
|
if (this.envConfig.isRemoteContentEnabled() && window.location.protocol !== 'file:') {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// UTILISER LE PROXY LOCAL au lieu de DigitalOcean directement
|
|
const remoteUrl = `http://localhost:8083/do-proxy/${filename}`;
|
|
logSh(`🔍 Test existence via proxy: ${remoteUrl}`, 'DEBUG');
|
|
|
|
const response = await fetch(remoteUrl, {
|
|
method: 'HEAD',
|
|
signal: controller.signal
|
|
});
|
|
|
|
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 {
|
|
// Vérifier d'abord si le module est déjà chargé
|
|
if (!window.ContentModules || !window.ContentModules[moduleName]) {
|
|
// Le module n'est pas encore chargé, on doit le charger
|
|
// Détecter le type de fichier et charger en conséquence
|
|
if (filename.endsWith('.json')) {
|
|
// Fichier JSON - essayer de le charger via proxy ou local
|
|
await this.loadJsonContent(filename);
|
|
} else {
|
|
// Fichier JS - charger le script classique
|
|
await this.loadScript(`js/content/${filename}`);
|
|
}
|
|
|
|
// Vérifier si le module existe après chargement
|
|
if (!window.ContentModules || !window.ContentModules[moduleName]) {
|
|
throw new Error(`Module ${moduleName} non trouvé après chargement`);
|
|
}
|
|
} else {
|
|
logSh(`✓ Module ${moduleName} déjà chargé, pas besoin de recharger`, 'INFO');
|
|
}
|
|
|
|
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) {
|
|
// Analyser les capacités du contenu ultra-modulaire
|
|
let capabilities;
|
|
try {
|
|
capabilities = this.analyzeContentCapabilities(module);
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur dans analyzeContentCapabilities pour ${contentId}: ${error.message}`, 'WARN');
|
|
// Fallback avec capacités basiques
|
|
capabilities = {
|
|
hasVocabulary: !!module.vocabulary,
|
|
hasSentences: !!module.sentences,
|
|
hasGrammar: !!module.grammar,
|
|
hasBasicContent: true
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: module.id || contentId,
|
|
filename: filename,
|
|
name: module.name || this.beautifyContentId(contentId),
|
|
description: module.description || 'Contenu automatiquement détecté',
|
|
|
|
// Ultra-modular metadata
|
|
difficulty_level: module.difficulty_level,
|
|
original_lang: module.original_lang,
|
|
user_lang: module.user_lang,
|
|
tags: module.tags || [],
|
|
skills_covered: module.skills_covered || [],
|
|
target_audience: module.target_audience || {},
|
|
estimated_duration: module.estimated_duration,
|
|
|
|
// Content capabilities analysis
|
|
capabilities: capabilities,
|
|
compatibility: this.safeCalculateGameCompatibility(capabilities),
|
|
icon: this.safeGetContentIcon(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.safeAnalyzeGameCompatibility(module)
|
|
};
|
|
}
|
|
|
|
safeCalculateGameCompatibility(capabilities) {
|
|
try {
|
|
return this.calculateGameCompatibility(capabilities);
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur dans calculateGameCompatibility: ${error.message}`, 'WARN');
|
|
return {}; // Retourner un objet vide en cas d'erreur
|
|
}
|
|
}
|
|
|
|
safeGetContentIcon(module, contentId) {
|
|
try {
|
|
return this.getContentIcon(module, contentId);
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur dans getContentIcon: ${error.message}`, 'WARN');
|
|
return '📚'; // Icône par défaut
|
|
}
|
|
}
|
|
|
|
safeAnalyzeGameCompatibility(module) {
|
|
try {
|
|
return this.analyzeGameCompatibility(module);
|
|
} catch (error) {
|
|
logSh(`⚠️ Erreur dans analyzeGameCompatibility: ${error.message}`, 'WARN');
|
|
return {}; // Retourner un objet vide en cas d'erreur
|
|
}
|
|
}
|
|
|
|
extractContentId(filename) {
|
|
return filename.replace('.js', '').toLowerCase();
|
|
}
|
|
|
|
getModuleName(contentId) {
|
|
const mapping = {
|
|
'sbs-level-7-8-new': 'SBSLevel78New',
|
|
'sbs-level-1': 'SBSLevel1',
|
|
'chinese-long-story': 'ChineseLongStory',
|
|
'french-beginner-story': 'FrenchBeginnerStory',
|
|
'wta1b1': 'WTA1B1',
|
|
'story-prototype-optimized': 'StoryPrototypeOptimized',
|
|
'test-compatibility': 'TestMinimalContent',
|
|
'test-minimal': 'TestMinimal',
|
|
'test-rich': 'TestRich',
|
|
// Ajout des modules NCE
|
|
'nce1-lesson63-64': 'NCE1Lesson6364',
|
|
'nce2-lesson3': 'NCE2Lesson3',
|
|
'nce2-lesson30': 'NCE2Lesson30'
|
|
};
|
|
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(filename) {
|
|
logSh(`🔍 Chargement JSON: ${filename}`, 'INFO');
|
|
|
|
let loaded = false;
|
|
let lastError = null;
|
|
|
|
// Méthode 1: Essayer distant via proxy (si configuré)
|
|
if (this.shouldTryRemote()) {
|
|
try {
|
|
const result = await this.tryRemoteLoad(filename);
|
|
if (result.success) {
|
|
logSh(`✅ JSON chargé depuis distant: ${filename}`, 'INFO');
|
|
loaded = true;
|
|
}
|
|
} catch (error) {
|
|
logSh(`⚠️ Distant échoué pour ${filename}: ${error.message}`, 'WARN');
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
// Méthode 2: Essayer local
|
|
if (!loaded) {
|
|
try {
|
|
const localUrl = `js/content/${filename}`;
|
|
const response = await fetch(localUrl);
|
|
if (response.ok) {
|
|
const jsonData = await response.json();
|
|
const moduleName = this.jsonFilenameToModuleName(filename);
|
|
|
|
window.ContentModules = window.ContentModules || {};
|
|
window.ContentModules[moduleName] = jsonData;
|
|
|
|
logSh(`✅ JSON chargé depuis local: ${filename}`, 'INFO');
|
|
loaded = true;
|
|
}
|
|
} catch (error) {
|
|
logSh(`⚠️ Local échoué pour ${filename}: ${error.message}`, 'WARN');
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
if (!loaded) {
|
|
throw new Error(`Impossible de charger JSON ${filename}: ${lastError?.message || 'toutes les méthodes ont échoué'}`);
|
|
}
|
|
}
|
|
|
|
jsonFilenameToModuleName(src) {
|
|
// Extraire le nom du fichier et le convertir en PascalCase compatible
|
|
const filename = src.split('/').pop().replace('.json', '');
|
|
|
|
// Mapping spécifique pour certains noms de fichiers
|
|
const specialMappings = {
|
|
'sbs-level-7-8-new': 'SBSLevel78New',
|
|
'sbs-level-1': 'SBSLevel1',
|
|
'english-class-demo': 'EnglishClassDemo',
|
|
'chinese-long-story': 'ChineseLongStory',
|
|
'french-beginner-story': 'FrenchBeginnerStory',
|
|
'wta1b1': 'WTA1B1',
|
|
'story-prototype-optimized': 'StoryPrototypeOptimized',
|
|
// Ajout des modules NCE
|
|
'nce1-lesson63-64': 'NCE1Lesson6364',
|
|
'nce2-lesson3': 'NCE2Lesson3',
|
|
'nce2-lesson30': 'NCE2Lesson30'
|
|
};
|
|
|
|
return specialMappings[filename] || 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)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyse les capacités d'un module ultra-modulaire
|
|
*/
|
|
analyzeContentCapabilities(module) {
|
|
const capabilities = {
|
|
// Core content analysis
|
|
hasVocabulary: this.hasContent(module, 'vocabulary'),
|
|
hasSentences: this.hasContent(module, 'sentences'),
|
|
hasGrammar: this.hasContent(module, 'grammar'),
|
|
hasAudio: this.hasContent(module, 'audio'),
|
|
hasDialogues: this.hasContent(module, 'dialogues'),
|
|
hasPoems: this.hasContent(module, 'poems'),
|
|
hasCulture: this.hasContent(module, 'culture'),
|
|
|
|
// Advanced features
|
|
hasExercises: this.hasExercises(module),
|
|
hasMatching: this.hasContent(module, 'matching'),
|
|
hasFillInBlanks: this.hasContent(module, 'fillInBlanks'),
|
|
hasCorrections: this.hasContent(module, 'corrections'),
|
|
hasComprehension: this.hasContent(module, 'comprehension'),
|
|
hasParametricSentences: this.hasContent(module, 'parametric_sentences'),
|
|
|
|
// Multimedia capabilities
|
|
hasAudioFiles: this.hasAudioFiles(module),
|
|
hasImages: this.hasImages(module),
|
|
hasVideos: this.hasVideos(module),
|
|
hasIPA: this.hasIPA(module),
|
|
|
|
// Metadata richness
|
|
hasDetailedMetadata: this.hasDetailedMetadata(module),
|
|
hasProgressiveDifficulty: this.hasProgressiveDifficulty(module),
|
|
hasMultipleLanguages: this.hasMultipleLanguages(module),
|
|
hasCulturalContext: this.hasCulturalContext(module),
|
|
|
|
// Content depth levels
|
|
vocabularyDepth: this.analyzeVocabularyDepth(module),
|
|
contentRichness: this.analyzeContentRichness(module)
|
|
};
|
|
|
|
return capabilities;
|
|
}
|
|
|
|
/**
|
|
* Calcule la compatibilité avec les jeux selon les capacités
|
|
*/
|
|
calculateGameCompatibility(capabilities) {
|
|
const games = {
|
|
'whack-a-mole': this.calculateWhackAMoleCompat(capabilities),
|
|
'memory-match': this.calculateMemoryMatchCompat(capabilities),
|
|
'quiz-game': this.calculateQuizGameCompat(capabilities),
|
|
'fill-the-blank': this.calculateFillBlankCompat(capabilities),
|
|
'adventure-reader': this.calculateAdventureCompat(capabilities),
|
|
'sentence-builder': this.calculateSentenceBuilderCompat(capabilities),
|
|
'pronunciation-game': this.calculatePronunciationCompat(capabilities),
|
|
'culture-explorer': this.calculateCultureCompat(capabilities)
|
|
};
|
|
|
|
return games;
|
|
}
|
|
|
|
/**
|
|
* Vérifie si un module a un type de contenu spécifique
|
|
*/
|
|
hasContent(module, contentType) {
|
|
const content = module[contentType];
|
|
if (!content) return false;
|
|
|
|
if (Array.isArray(content)) return content.length > 0;
|
|
if (typeof content === 'object') return Object.keys(content).length > 0;
|
|
return !!content;
|
|
}
|
|
|
|
/**
|
|
* Vérifie si un module a des exercices
|
|
*/
|
|
hasExercises(module) {
|
|
return this.hasContent(module, 'exercises') ||
|
|
this.hasContent(module, 'fillInBlanks') ||
|
|
this.hasContent(module, 'corrections') ||
|
|
this.hasContent(module, 'comprehension') ||
|
|
this.hasContent(module, 'matching');
|
|
}
|
|
|
|
/**
|
|
* Vérifie la présence de fichiers audio
|
|
*/
|
|
hasAudioFiles(module) {
|
|
// Vérifier dans le vocabulaire
|
|
if (module.vocabulary) {
|
|
for (const word of Object.values(module.vocabulary)) {
|
|
if (typeof word === 'object' && (word.audio_file || word.audio || word.pronunciation)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vérifier dans l'audio dédié
|
|
return this.hasContent(module, 'audio');
|
|
}
|
|
|
|
/**
|
|
* Analyse la profondeur du vocabulaire (niveaux 1-6)
|
|
*/
|
|
analyzeVocabularyDepth(module) {
|
|
if (!module.vocabulary) return 0;
|
|
|
|
let maxDepth = 1; // Au minimum niveau 1 (string simple)
|
|
|
|
for (const definition of Object.values(module.vocabulary)) {
|
|
if (typeof definition === 'object') {
|
|
maxDepth = Math.max(maxDepth, 2); // Niveau 2: objet de base
|
|
|
|
if (definition.examples || definition.grammar_notes) maxDepth = Math.max(maxDepth, 3);
|
|
if (definition.etymology || definition.word_family) maxDepth = Math.max(maxDepth, 4);
|
|
if (definition.cultural_significance) maxDepth = Math.max(maxDepth, 5);
|
|
if (definition.memory_techniques || definition.visual_associations) maxDepth = Math.max(maxDepth, 6);
|
|
}
|
|
}
|
|
|
|
return maxDepth;
|
|
}
|
|
|
|
/**
|
|
* Calculs de compatibilité spécifiques par jeu
|
|
*/
|
|
calculateWhackAMoleCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasVocabulary) score += 40;
|
|
if (capabilities.hasSentences) score += 30;
|
|
if (capabilities.hasAudioFiles) score += 20;
|
|
if (capabilities.vocabularyDepth >= 2) score += 10;
|
|
|
|
return { compatible: score >= 40, score, reason: 'Nécessite vocabulaire ou phrases' };
|
|
}
|
|
|
|
calculateMemoryMatchCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasVocabulary) score += 50;
|
|
if (capabilities.hasImages) score += 30;
|
|
if (capabilities.hasAudioFiles) score += 20;
|
|
|
|
return { compatible: score >= 50, score, reason: 'Optimisé pour vocabulaire visuel' };
|
|
}
|
|
|
|
calculateQuizGameCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasVocabulary) score += 30;
|
|
if (capabilities.hasGrammar) score += 25;
|
|
if (capabilities.hasExercises) score += 45;
|
|
|
|
return { compatible: score >= 30, score, reason: 'Fonctionne avec tout contenu' };
|
|
}
|
|
|
|
calculateFillBlankCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasFillInBlanks) score += 70;
|
|
if (capabilities.hasSentences) score += 30;
|
|
|
|
return { compatible: score >= 30, score, reason: 'Nécessite phrases à trous' };
|
|
}
|
|
|
|
|
|
calculateAdventureCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasDialogues) score += 60;
|
|
if (capabilities.hasCulture) score += 30;
|
|
if (capabilities.contentRichness >= 5) score += 10;
|
|
|
|
return { compatible: score >= 50, score, reason: 'Nécessite dialogues riches' };
|
|
}
|
|
|
|
calculateSentenceBuilderCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasParametricSentences) score += 70;
|
|
if (capabilities.hasSentences) score += 30;
|
|
|
|
return { compatible: score >= 30, score, reason: 'Construit des phrases' };
|
|
}
|
|
|
|
calculatePronunciationCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasAudioFiles) score += 60;
|
|
if (capabilities.hasIPA) score += 40;
|
|
|
|
return { compatible: score >= 60, score, reason: 'Nécessite fichiers audio' };
|
|
}
|
|
|
|
calculateCultureCompat(capabilities) {
|
|
let score = 0;
|
|
if (capabilities.hasCulture) score += 60;
|
|
if (capabilities.hasPoems) score += 30;
|
|
if (capabilities.hasCulturalContext) score += 10;
|
|
|
|
return { compatible: score >= 30, score, reason: 'Nécessite contenu culturel' };
|
|
}
|
|
|
|
/**
|
|
* Analyse les capacités globales de tous les modules
|
|
*/
|
|
analyzeGlobalCapabilities(contentList) {
|
|
const globalStats = {
|
|
totalCapabilities: new Set(),
|
|
averageRichness: 0,
|
|
recommendedGames: new Map(),
|
|
contentGaps: []
|
|
};
|
|
|
|
let totalRichness = 0;
|
|
|
|
for (const content of contentList) {
|
|
// Compiler toutes les capacités
|
|
Object.keys(content.capabilities).forEach(cap => {
|
|
if (content.capabilities[cap]) globalStats.totalCapabilities.add(cap);
|
|
});
|
|
|
|
totalRichness += content.capabilities.contentRichness || 0;
|
|
|
|
// Analyser la compatibilité des jeux
|
|
Object.entries(content.compatibility || {}).forEach(([game, compat]) => {
|
|
if (compat.compatible) {
|
|
const current = globalStats.recommendedGames.get(game) || { count: 0, avgScore: 0 };
|
|
current.count++;
|
|
current.avgScore = (current.avgScore + compat.score) / current.count;
|
|
globalStats.recommendedGames.set(game, current);
|
|
}
|
|
});
|
|
}
|
|
|
|
globalStats.averageRichness = totalRichness / contentList.length;
|
|
globalStats.totalCapabilities = Array.from(globalStats.totalCapabilities);
|
|
|
|
logSh(`📊 Analyse globale: ${globalStats.totalCapabilities.length} capacités, richesse moyenne: ${globalStats.averageRichness.toFixed(1)}`, 'INFO');
|
|
|
|
return globalStats;
|
|
}
|
|
|
|
// Helper methods pour les analyses spécialisées
|
|
hasImages(module) { return false; } // TODO: implémenter
|
|
hasVideos(module) { return false; }
|
|
hasIPA(module) { return module.vocabulary && Object.values(module.vocabulary).some(w => typeof w === 'object' && w.ipa); }
|
|
hasDetailedMetadata(module) { return !!(module.tags && module.skills_covered && module.target_audience); }
|
|
hasProgressiveDifficulty(module) { return !!(module.difficulty_level && typeof module.difficulty_level === 'number'); }
|
|
hasMultipleLanguages(module) { return !!(module.original_lang && module.user_lang); }
|
|
hasCulturalContext(module) { return this.hasContent(module, 'culture') || this.hasContent(module, 'poems'); }
|
|
analyzeContentRichness(module) { return this.countItems(module) / 10; } // Score sur 10 basé sur le nombre d'items
|
|
}
|
|
|
|
// Export global
|
|
window.ContentScanner = ContentScanner; |