/**
* Content-Registry — Zentraler Ressourcen-Katalog.
*
* Lädt die Content-Registry aus config/content-registry.json
* und bietet eine Such- und Filter-API für alle Plattform-Inhalte.
*
* @fileoverview
* @author Alexander Wolf
* @version 1.0
* @see docs/conventions/DATA_MODEL_CONVENTIONS.md §1
*/
/** @type {Object|null} Gecachte Registry-Daten */
let _cache = null;
/** @type {Promise|null} Laufender Ladevorgang */
let _loadPromise = null;
/**
* Zentraler Zugriffspunkt auf die Content-Registry.
* Singleton-Pattern: Die Registry wird einmal geladen und gecacht.
*
* @example
* import { ContentRegistry } from './js/core/content-registry.js';
*
* const registry = await ContentRegistry.load();
* const games = registry.getByType('game');
* const resource = registry.getById('game_rotatebox');
* const searchResults = registry.search('suchbaum');
*/
class ContentRegistry {
/**
* @param {Object} data - Geparste content-registry.json
*/
constructor(data) {
/** @type {number} Schema-Version */
this.version = data.version;
/** @type {Object[]} Alle Ressourcen */
this.resources = data.resources;
/** @type {Map<string, Object>} ID → Ressource Lookup */
this._byId = new Map();
/** @type {Map<string, Object[]>} Type → Ressourcen Lookup */
this._byType = new Map();
this._buildIndices();
}
// ── Factory ─────────────────────────────────────────────────
/**
* Lädt die Registry aus der JSON-Datei (Singleton, gecacht).
*
* @param {string} [basePath=''] - Optionaler Basis-Pfad für den Fetch
* (z.B. '../' wenn aus html/games/ geladen)
* @returns {Promise<ContentRegistry>}
*/
static async load(basePath = '') {
if (_cache) return _cache;
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
// file://-Fallback: window.CONTENT_REGISTRY_DATA nutzen (gesetzt von content-registry-data.js)
// @see docs/specifications/ES_MODULES_CORS_EVALUATION.md §1.3
if (typeof window !== 'undefined' && window.CONTENT_REGISTRY_DATA) {
_cache = new ContentRegistry(window.CONTENT_REGISTRY_DATA);
return _cache;
}
const sep = basePath && !basePath.endsWith('/') ? '/' : '';
const url = `${basePath}${sep}config/content-registry.json`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
_cache = new ContentRegistry(data);
return _cache;
} catch (err) {
_loadPromise = null;
console.error('[ContentRegistry] Fehler beim Laden:', err);
throw err;
}
})();
return _loadPromise;
}
/**
* Setzt den Cache zurück (z.B. für Tests).
*/
static clearCache() {
_cache = null;
_loadPromise = null;
}
// ── Query API ───────────────────────────────────────────────
/**
* Gibt eine Ressource anhand ihrer ID zurück.
* @param {string} id - Content-Registry ID
* @returns {Object|undefined}
*/
getById(id) {
return this._byId.get(id);
}
/**
* Gibt alle Ressourcen eines Typs zurück.
* @param {'game'|'playground'|'expedition'|'challenge-galaxy'|'edu-material'} type
* @returns {Object[]}
*/
getByType(type) {
return this._byType.get(type) || [];
}
/**
* Gibt alle Ressourcen zurück, die einen bestimmten Topic-Tag haben.
* @param {string} topic - Topic-Tag (z.B. 'search-trees')
* @returns {Object[]}
*/
getByTopic(topic) {
return this.resources.filter(r => r.topics.includes(topic));
}
/**
* Gibt alle Ressourcen zurück, die für ein bestimmtes Level geeignet sind.
* @param {'beginner'|'intermediate'|'advanced'} level
* @returns {Object[]}
*/
getByLevel(level) {
const maxDifficulty = { beginner: 2, intermediate: 3, advanced: 5 };
const max = maxDifficulty[level] || 5;
return this.resources.filter(r => r.difficulty <= max);
}
/**
* Gibt alle Ressourcen zurück, die für eine bestimmte Zielgruppe geeignet sind.
* @param {'beginner'|'academic'|'student'} audience
* @returns {Object[]}
*/
getByAudience(audience) {
return this.resources.filter(r =>
r.targetAudience && r.targetAudience.includes(audience)
);
}
/**
* Gibt alle verwandten Ressourcen einer gegebenen Ressource zurück.
* @param {string} resourceId - Content-Registry ID
* @returns {Object[]} Aufgelöste Ressourcen-Objekte
*/
getRelated(resourceId) {
const resource = this.getById(resourceId);
if (!resource || !resource.relatedResources) return [];
return resource.relatedResources
.map(id => this.getById(id))
.filter(Boolean);
}
/**
* Gibt die Voraussetzungen einer Ressource zurück.
* @param {string} resourceId - Content-Registry ID
* @returns {Object[]} Aufgelöste Ressourcen-Objekte
*/
getPrerequisites(resourceId) {
const resource = this.getById(resourceId);
if (!resource || !resource.prerequisites) return [];
return resource.prerequisites
.map(id => this.getById(id))
.filter(Boolean);
}
/**
* Volltextsuche über Titel, Beschreibung, Topics und Glossar-Terms.
* @param {string} query - Suchbegriff (case-insensitive)
* @returns {Object[]} Passende Ressourcen, sortiert nach Relevanz
*/
search(query) {
const q = query.toLowerCase().trim();
if (!q) return [];
const results = [];
for (const r of this.resources) {
let score = 0;
// Titel (höchste Gewichtung)
if (r.title.toLowerCase().includes(q)) score += 10;
// Beschreibung
if (r.description.toLowerCase().includes(q)) score += 5;
// Topics
if (r.topics.some(t => t.includes(q))) score += 3;
// Glossar-Terms
if (r.glossaryTerms && r.glossaryTerms.some(t => t.includes(q))) score += 2;
// Slug
if (r.slug.includes(q)) score += 1;
if (score > 0) {
results.push({ resource: r, score });
}
}
return results
.sort((a, b) => b.score - a.score)
.map(r => r.resource);
}
/**
* Gibt Empfehlungen basierend auf abgeschlossenen Ressourcen und Interessen.
* @param {Object} options
* @param {string[]} [options.completedIds=[]] - Bereits abgeschlossene Ressourcen-IDs
* @param {string[]} [options.interests=[]] - Topic-Tags des Nutzers
* @param {string[]} [options.dismissed=[]] - Weggeklickte Empfehlungen
* @param {number} [options.maxResults=5] - Maximale Anzahl Empfehlungen
* @returns {Object[]} Empfohlene Ressourcen
*/
getRecommendations({ completedIds = [], interests = [], dismissed = [], maxResults = 5 } = {}) {
const completedSet = new Set(completedIds);
const dismissedSet = new Set(dismissed);
const candidates = this.resources.filter(r =>
!completedSet.has(r.id) && !dismissedSet.has(r.id)
);
const scored = candidates.map(r => {
let score = 0;
// Bonus für passende Interessen
for (const topic of r.topics) {
if (interests.includes(topic)) score += 5;
}
// Bonus, wenn Voraussetzungen erfüllt sind
if (r.prerequisites.length > 0) {
const allMet = r.prerequisites.every(id => completedSet.has(id));
if (allMet) score += 8;
else score -= 10; // Voraussetzungen nicht erfüllt → deprioritisieren
}
// Bonus für verwandte abgeschlossene Ressourcen
if (r.relatedResources) {
const relatedCompleted = r.relatedResources.filter(id => completedSet.has(id)).length;
score += relatedCompleted * 2;
}
// Leichter Bonus für niedrigere Schwierigkeit (für Einsteiger)
if (completedIds.length < 3) {
score += (5 - r.difficulty);
}
return { resource: r, score };
});
return scored
.filter(s => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map(s => s.resource);
}
/**
* Gibt alle verfügbaren Topic-Tags zurück.
* @returns {string[]} Sortierte, deduplizierte Topic-Tags
*/
getAllTopics() {
const topics = new Set();
this.resources.forEach(r => r.topics.forEach(t => topics.add(t)));
return [...topics].sort();
}
/**
* Gibt die Gesamtzahl der Ressourcen zurück.
* @returns {number}
*/
get count() {
return this.resources.length;
}
// ── Private ─────────────────────────────────────────────────
/**
* Baut die internen Lookup-Indizes auf.
* @private
*/
_buildIndices() {
for (const resource of this.resources) {
// ID-Index (immer, damit getById auch für hidden funktioniert)
this._byId.set(resource.id, resource);
// Type-Index (hidden-Einträge ausblenden)
if (!resource.hidden) {
if (!this._byType.has(resource.type)) {
this._byType.set(resource.type, []);
}
this._byType.get(resource.type).push(resource);
}
// Varianten in den ID-Index aufnehmen
if (resource.variants) {
for (const variant of resource.variants) {
this._byId.set(variant.id, { ...variant, _parent: resource.id });
}
}
// Challenge-Missionen in den ID-Index aufnehmen
if (resource.missions) {
for (const mission of resource.missions) {
this._byId.set(mission.id, { ...mission, _parent: resource.id });
}
}
// Expeditions-Kapitel in den ID-Index aufnehmen
if (resource.chapters) {
for (const chapter of resource.chapters) {
this._byId.set(chapter.id, { ...chapter, _parent: resource.id });
}
}
}
}
}
// Globale Verfügbarkeit für file://-Kompatibilität (Classic-Script-Pattern)
// @see docs/specifications/ES_MODULES_CORS_EVALUATION.md §1.3
// HINWEIS: export-Statement hier bewusst entfernt — Datei wird als
// klassisches <script src> geladen. ContentRegistry ist via window global verfügbar.
if (typeof window !== 'undefined') {
window.ContentRegistry = ContentRegistry;
}