diff --git a/_config.yml b/_config.yml index c1c2e3a..85216ab 100644 --- a/_config.yml +++ b/_config.yml @@ -23,9 +23,17 @@ bibtex_bibfiles: # reference style sphinx: + extra_extensions: + - sphinx_apa_references config: bibtex_reference_style: author_year html_show_copyright: false + html_static_path: ["_static"] + html_css_files: + - quadriga.css + - carousel.css + html_js_files: + - carousel.js # Information about where the book exists on the web #repository: @@ -45,7 +53,7 @@ html: use_repository_button: true extra_footer:
Dieses Werk ist lizenziert unter der Lizenz CC BY-SA 4.0. Detailliertere Informationen finden Sie unter LICENSE.md
Diese OER wurde im Rahmen des Projekts QUADRIGA erstellt. Das Projekt wurde gefördert durch das Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR). Eine Kurzvorstellung des Projekts finden Sie im Abschluss.
+Diese OER wurde im Rahmen des Projekts QUADRIGA erstellt. Das Projekt wurde gefördert durch das Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR). Mehr Informationen zum Projekt finden Sie auch im abschluss.
launch_buttons: notebook_interface: jupyterlab diff --git a/_static/assessment.js b/_static/assessment.js index 9e93fa5..505f418 100644 --- a/_static/assessment.js +++ b/_static/assessment.js @@ -1,227 +1,237 @@ // Function to lock an answer after submission function lockAnswer(id) { - const textarea = document.getElementById(id); - const button = document.getElementById('btn-' + id); - - if (textarea.value.trim() === '') { - alert('Please enter your answer before submitting.'); - return; - } - - textarea.readOnly = true; - textarea.style.fontWeight = 'bold'; - textarea.style.color = '#666'; - textarea.style.backgroundColor = '#f8f9fa'; - button.style.display = 'none'; + const textarea = document.getElementById(id); + const button = document.getElementById("btn-" + id); + + if (textarea.value.trim() === "") { + alert("Please enter your answer before submitting."); + return; } + textarea.readOnly = true; + textarea.style.fontWeight = "bold"; + textarea.style.color = "#666"; + textarea.style.backgroundColor = "#f8f9fa"; + button.style.display = "none"; +} class DragDropQuizManager { - constructor() { - this.quizzes = new Map(); - } - - initializeQuiz(quizId, correctPairs, showFeedback, originalOptions, customFeedback) { - const quizData = { - quizId, - correctPairs, - showFeedback, - originalOptions, - customFeedback, - draggedElement: null - }; - - this.quizzes.set(quizId, quizData); - this.initializeOptions(quizId); - this.setupEventListeners(quizId); - this.createGlobalFunctions(quizId); + constructor() { + this.quizzes = new Map(); + } + + initializeQuiz( + quizId, + correctPairs, + showFeedback, + originalOptions, + customFeedback, + ) { + const quizData = { + quizId, + correctPairs, + showFeedback, + originalOptions, + customFeedback, + draggedElement: null, + }; + + this.quizzes.set(quizId, quizData); + this.initializeOptions(quizId); + this.setupEventListeners(quizId); + this.createGlobalFunctions(quizId); + } + + // Shuffle array function + shuffleArray(array) { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - - // Shuffle array function - shuffleArray(array) { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + return shuffled; + } + + // Initialize options with shuffling + initializeOptions(quizId) { + const quiz = this.quizzes.get(quizId); + const optionsList = document.getElementById(`${quizId}_options`); + const shuffledOptions = this.shuffleArray( + quiz.originalOptions.map((option, index) => ({ option, index })), + ); + + optionsList.innerHTML = ""; + shuffledOptions.forEach((item) => { + const draggableOption = document.createElement("div"); + draggableOption.className = "draggable-option"; + draggableOption.draggable = true; + draggableOption.dataset.itemId = item.index; + draggableOption.id = `${quizId}_option_${item.index}`; + draggableOption.textContent = item.option; + optionsList.appendChild(draggableOption); + }); + + this.initializeDragEvents(quizId); + } + + // Add event listeners to draggable options + initializeDragEvents(quizId) { + const quiz = this.quizzes.get(quizId); + const draggableOptions = document.querySelectorAll( + `#${quizId} .draggable-option`, + ); + + draggableOptions.forEach((option) => { + option.addEventListener("dragstart", (e) => { + quiz.draggedElement = option; + option.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/html", option.outerHTML); + }); + + option.addEventListener("dragend", (e) => { + option.classList.remove("dragging"); + }); + }); + } + + setupEventListeners(quizId) { + const quiz = this.quizzes.get(quizId); + const dropAreas = document.querySelectorAll(`#${quizId} .drop-area`); + + dropAreas.forEach((area) => { + area.addEventListener("dragover", (e) => { + e.preventDefault(); + area.classList.add("drag-over"); + }); + + area.addEventListener("dragleave", (e) => { + area.classList.remove("drag-over"); + }); + + area.addEventListener("drop", (e) => { + e.preventDefault(); + area.classList.remove("drag-over"); + + if (quiz.draggedElement) { + // Clear the drop area + area.innerHTML = ""; + + // Create a new element for the dropped item + const droppedItem = document.createElement("div"); + droppedItem.className = "dropped-item"; + droppedItem.textContent = quiz.draggedElement.textContent; + droppedItem.dataset.itemId = quiz.draggedElement.dataset.itemId; + + // Add click listener to return item to original position + droppedItem.addEventListener("click", () => { + this.returnItemToOriginal(quizId, droppedItem); + }); + + area.appendChild(droppedItem); + + // Remove the original draggable option + quiz.draggedElement.remove(); + quiz.draggedElement = null; } - return shuffled; - } - - // Initialize options with shuffling - initializeOptions(quizId) { - const quiz = this.quizzes.get(quizId); - const optionsList = document.getElementById(`${quizId}_options`); - const shuffledOptions = this.shuffleArray(quiz.originalOptions.map((option, index) => ({ option, index }))); - - optionsList.innerHTML = ''; - shuffledOptions.forEach(item => { - const draggableOption = document.createElement('div'); - draggableOption.className = 'draggable-option'; - draggableOption.draggable = true; - draggableOption.dataset.itemId = item.index; - draggableOption.id = `${quizId}_option_${item.index}`; - draggableOption.textContent = item.option; - optionsList.appendChild(draggableOption); - }); - - this.initializeDragEvents(quizId); - } - - // Add event listeners to draggable options - initializeDragEvents(quizId) { - const quiz = this.quizzes.get(quizId); - const draggableOptions = document.querySelectorAll(`#${quizId} .draggable-option`); - - draggableOptions.forEach(option => { - option.addEventListener('dragstart', (e) => { - quiz.draggedElement = option; - option.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/html', option.outerHTML); - }); - - option.addEventListener('dragend', (e) => { - option.classList.remove('dragging'); - }); - }); - } - - setupEventListeners(quizId) { - const quiz = this.quizzes.get(quizId); - const dropAreas = document.querySelectorAll(`#${quizId} .drop-area`); - - dropAreas.forEach(area => { - area.addEventListener('dragover', (e) => { - e.preventDefault(); - area.classList.add('drag-over'); - }); - - area.addEventListener('dragleave', (e) => { - area.classList.remove('drag-over'); - }); - - area.addEventListener('drop', (e) => { - e.preventDefault(); - area.classList.remove('drag-over'); - - if (quiz.draggedElement) { - // Clear the drop area - area.innerHTML = ''; - - // Create a new element for the dropped item - const droppedItem = document.createElement('div'); - droppedItem.className = 'dropped-item'; - droppedItem.textContent = quiz.draggedElement.textContent; - droppedItem.dataset.itemId = quiz.draggedElement.dataset.itemId; - - // Add click listener to return item to original position - droppedItem.addEventListener('click', () => { - this.returnItemToOriginal(quizId, droppedItem); - }); - - area.appendChild(droppedItem); - - // Remove the original draggable option - quiz.draggedElement.remove(); - quiz.draggedElement = null; - } - }); - }); - } - - // Function to return item to original position - returnItemToOriginal(quizId, droppedItem) { - const optionsList = document.getElementById(`${quizId}_options`); - - // Create new draggable option - const newDraggableOption = document.createElement('div'); - newDraggableOption.className = 'draggable-option'; - newDraggableOption.draggable = true; - newDraggableOption.dataset.itemId = droppedItem.dataset.itemId; - newDraggableOption.id = `${quizId}_option_${droppedItem.dataset.itemId}`; - newDraggableOption.textContent = droppedItem.textContent; - - optionsList.appendChild(newDraggableOption); - - // Remove from drop zone and restore placeholder - const dropArea = droppedItem.parentElement; - dropArea.innerHTML = 'Hier ablegen'; - - // Re-initialize drag events for the new option - this.initializeDragEvents(quizId); - } - - // Check answer function - checkAnswer(quizId) { - const quiz = this.quizzes.get(quizId); - if (!quiz.showFeedback) return; - - const feedback = document.getElementById(`${quizId}_feedback`); - let correctCount = 0; - let totalPairs = quiz.correctPairs.length; - - // Check each correct pair and apply visual feedback - quiz.correctPairs.forEach(([descId, optionId]) => { - const dropArea = document.getElementById(`${quizId}_drop_${descId}`); - const droppedItem = dropArea.querySelector('.dropped-item'); - - // Remove previous feedback classes - dropArea.classList.remove('correct-answer', 'incorrect-answer'); - - if (droppedItem) { - if (parseInt(droppedItem.dataset.itemId) === optionId) { - correctCount++; - dropArea.classList.add('correct-answer'); - } else { - dropArea.classList.add('incorrect-answer'); - } - } else { - // Empty drop area is also incorrect - dropArea.classList.add('incorrect-answer'); - } - }); - - let message, className; - if (correctCount === totalPairs) { - message = quiz.customFeedback.correct.replace('{total}', totalPairs); - className = 'correct'; - } else if (correctCount === 0) { - message = quiz.customFeedback.incorrect; - className = 'incorrect'; + }); + }); + } + + // Function to return item to original position + returnItemToOriginal(quizId, droppedItem) { + const optionsList = document.getElementById(`${quizId}_options`); + + // Create new draggable option + const newDraggableOption = document.createElement("div"); + newDraggableOption.className = "draggable-option"; + newDraggableOption.draggable = true; + newDraggableOption.dataset.itemId = droppedItem.dataset.itemId; + newDraggableOption.id = `${quizId}_option_${droppedItem.dataset.itemId}`; + newDraggableOption.textContent = droppedItem.textContent; + + optionsList.appendChild(newDraggableOption); + + // Remove from drop zone and restore placeholder + const dropArea = droppedItem.parentElement; + dropArea.innerHTML = 'Hier ablegen'; + + // Re-initialize drag events for the new option + this.initializeDragEvents(quizId); + } + + // Check answer function + checkAnswer(quizId) { + const quiz = this.quizzes.get(quizId); + if (!quiz.showFeedback) return; + + const feedback = document.getElementById(`${quizId}_feedback`); + let correctCount = 0; + let totalPairs = quiz.correctPairs.length; + + // Check each correct pair and apply visual feedback + quiz.correctPairs.forEach(([descId, optionId]) => { + const dropArea = document.getElementById(`${quizId}_drop_${descId}`); + const droppedItem = dropArea.querySelector(".dropped-item"); + + // Remove previous feedback classes + dropArea.classList.remove("correct-answer", "incorrect-answer"); + + if (droppedItem) { + if (parseInt(droppedItem.dataset.itemId) === optionId) { + correctCount++; + dropArea.classList.add("correct-answer"); } else { - message = quiz.customFeedback.partial - .replace('{correct}', correctCount) - .replace('{total}', totalPairs); - className = 'partial'; + dropArea.classList.add("incorrect-answer"); } - - feedback.innerHTML = `" + metadata.get("description") + "
" description_base = f""" @@ -279,61 +213,38 @@ def create_zenodo_json() -> bool | None: logger.info("Added description") # publication date - publication_date = None - if "date-modified" in metadata: - # Use date-modified from metadata.yml and convert to string if needed - date_value = metadata["date-modified"] - # Handle both date objects and strings - if hasattr(date_value, "isoformat"): - # It's a date/datetime object, convert to ISO format string - publication_date = date_value.isoformat() - else: - # It's already a string - publication_date = str(date_value) - logger.info("Added publication_date from metadata.yml: %s", publication_date) - elif "year" in pref: - # Fall back to year from CITATION.cff - year = str(pref["year"]) - # Zenodo expects ISO 8601 date format (YYYY-MM-DD) - # We use January 1st as default when only year is provided - publication_date = f"{year}-01-01" - logger.info("Added publication_date from year (fallback): %s", publication_date) - else: - logger.warning("No publication date or year found") + publication_date = get_publication_date(metadata) if publication_date: zenodo_metadata["publication_date"] = publication_date + logger.info("Added publication_date: %s", publication_date) + else: + logger.warning("No publication date found") # Note: DOI field is intentionally NOT included in .zenodo.json # Zenodo assigns DOIs automatically upon upload/release. # Including a self-referencing DOI would be circular and incorrect. # keywords - if pref.get("keywords"): - keywords_list = extract_keywords(pref["keywords"]) + if metadata.get("keywords"): + keywords_list = extract_keywords(metadata["keywords"]) if keywords_list: zenodo_metadata["keywords"] = keywords_list logger.info("Added %d keywords", len(keywords_list)) # license - license_id = None - if "license" in pref: - license_id = pref["license"] - elif "copyright" in pref: - license_id = pref["copyright"] + license_id = get_content_license(metadata) if license_id: - # Zenodo expects license IDs like "CC-BY-SA-4.0" + # Zenodo expects license IDs like "CC-BY-4.0" # Clean up common variations - license_clean = str(license_id).upper().replace("_", "-") + license_clean = str(license_id).upper().replace("_", "-").replace(" ", "-") zenodo_metadata["license"] = license_clean logger.info("Added license: %s", license_clean) - # language - if pref.get("languages"): - lang = ( - pref["languages"][0] if isinstance(pref["languages"], list) else pref["languages"] - ) - zenodo_metadata["language"] = lang - logger.info("Added language: %s", lang) + # language (Zenodo expects ISO 639-2/3 codes, e.g. "deu") + languages = get_languages(metadata) + if languages: + zenodo_metadata["language"] = languages[0] + logger.info("Added language: %s", languages[0]) # contributors if metadata.get("contributors"): @@ -344,17 +255,13 @@ def create_zenodo_json() -> bool | None: # related_identifiers related_identifiers = [] - repo_url = pref.get("repository-code") + repo_url = metadata.get("git") if repo_url: - related_identifiers.append( - {"identifier": repo_url, "relation": "isSupplementedBy", "scheme": "url"} - ) + related_identifiers.append({"identifier": repo_url, "relation": "isSupplementedBy", "scheme": "url"}) logger.info("Added repository URL as related identifier") - url = pref.get("url") + url = metadata.get("url") if url and url != repo_url: - related_identifiers.append( - {"identifier": url, "relation": "isAlternateIdentifier", "scheme": "url"} - ) + related_identifiers.append({"identifier": url, "relation": "isAlternateIdentifier", "scheme": "url"}) logger.info("Added URL as related identifier") if related_identifiers: @@ -365,9 +272,9 @@ def create_zenodo_json() -> bool | None: logger.info("Added QUADRIGA community") # version - if "version" in pref: - zenodo_metadata["version"] = str(pref["version"]) - logger.info("Added version: %s", pref["version"]) + if "version" in metadata: + zenodo_metadata["version"] = str(metadata["version"]) + logger.info("Added version: %s", metadata["version"]) # write .zenodo.json try: diff --git a/quadriga/metadata/extract_from_book_config.py b/quadriga/metadata/extract_from_book_config.py index ea3946d..5cd36b2 100644 --- a/quadriga/metadata/extract_from_book_config.py +++ b/quadriga/metadata/extract_from_book_config.py @@ -16,6 +16,7 @@ get_file_path, get_repo_root, load_yaml_file, + resolve_toc_file, save_yaml_file, ) @@ -89,23 +90,17 @@ def extract_and_update() -> bool | None: continue try: - # Get the file path as a Path object file_path_str = chapter["file"] - p = Path(file_path_str) - # Ensure the file has an extension (default to .md if none) - if p.suffix not in [".md", ".ipynb"]: - p = p.with_suffix(".md") - - # Create the full path to the file - full_path = get_file_path(p, repo_root) + # Resolve the _toc.yml entry to an existing file + full_path = resolve_toc_file(file_path_str, repo_root) # Check if file exists if not full_path.exists(): missing_files.append(str(full_path)) logger.warning("Chapter file not found: %s", full_path) # Use filename as fallback title - toc_chapters.append(f"[Missing: {p.stem}]") + toc_chapters.append(f"[Missing: {Path(file_path_str).name}]") continue # Extract the chapter title from the file's first heading @@ -117,7 +112,7 @@ def extract_and_update() -> bool | None: logger.exception("Error processing chapter %s", chapter.get("file", "unknown")) # Add a placeholder with the filename if possible try: - toc_chapters.append(f"[Error: {p.stem}]") + toc_chapters.append(f"[Error: {Path(chapter['file']).name}]") except Exception: toc_chapters.append("[Error: unknown chapter]") diff --git a/quadriga/metadata/extract_from_lernziele.py b/quadriga/metadata/extract_from_lernziele.py index 90a7892..bd7aee7 100644 --- a/quadriga/metadata/extract_from_lernziele.py +++ b/quadriga/metadata/extract_from_lernziele.py @@ -1,11 +1,20 @@ """Extract learning objectives with metadata from Lernziele.md files.""" from __future__ import annotations + import logging import re from pathlib import Path from typing import Any -from .utils import get_repo_root, save_yaml_file, get_file_path, load_yaml_file, iter_toc_files + +from .utils import ( + get_file_path, + get_repo_root, + iter_toc_files, + load_yaml_file, + resolve_toc_file, + save_yaml_file, +) logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -25,20 +34,20 @@ def parse_metadata_comment(comment: str) -> dict[str, str]: """Parse 'competency: X | bloom: Y' from the inner text of an HTML comment.""" metadata = {} - for part in comment.split('|'): + for part in comment.split("|"): part = part.strip() - if ':' not in part: + if ":" not in part: continue - key, value = part.split(':', 1) - key = key.strip().lower().replace(' ', '-') + key, value = part.split(":", 1) + key = key.strip().lower().replace(" ", "-") value = value.strip() if not value: continue - if key == 'bloom': - metadata['blooms-category'] = value - elif key == 'competency': + if key == "bloom": + metadata["blooms-category"] = value + elif key == "competency": metadata[key] = value - metadata['data-flow'] = derive_data_flow(value) + metadata["data-flow"] = derive_data_flow(value) return metadata @@ -47,14 +56,14 @@ def validate_objective_metadata(objective_data: dict[str, Any]) -> list[str]: """Fill in missing competency/bloom/data-flow defaults and return names of missing fields.""" missing_fields = [] - if not objective_data.get('competency'): - missing_fields.append('competency') - objective_data['competency'] = DEFAULT_COMPETENCY - objective_data['data-flow'] = DEFAULT_DATA_FLOW + if not objective_data.get("competency"): + missing_fields.append("competency") + objective_data["competency"] = DEFAULT_COMPETENCY + objective_data["data-flow"] = DEFAULT_DATA_FLOW - if not objective_data.get('blooms-category'): - missing_fields.append('blooms-category') - objective_data['blooms-category'] = DEFAULT_BLOOM + if not objective_data.get("blooms-category"): + missing_fields.append("blooms-category") + objective_data["blooms-category"] = DEFAULT_BLOOM return missing_fields @@ -92,7 +101,9 @@ def extract_admonition_blocks( validation_issues = [] # Pattern to match admonition blocks preceded by - admonition_pattern = r'\s*\n```\{admonition\}\s+(.+?)\n((?::[^\n]+\n)*)((?:(?!```).)+)```' + admonition_pattern = ( + r"\s*\n```\{admonition\}\s+(.+?)\n((?::[^\n]+\n)*)((?:(?!```).)+)```" + ) matches = re.finditer(admonition_pattern, content, re.DOTALL | re.MULTILINE) @@ -102,7 +113,7 @@ def extract_admonition_blocks( body = match.group(4).strip() # Parse title - extract text and reference - title_match = re.match(r'\[(.+?)\]\((.+?)\)(\s*\(\*(.+?)\*\))?', title_line) + title_match = re.match(r"\[(.+?)\]\((.+?)\)(\s*\(\*(.+?)\*\))?", title_line) if not title_match: logger.warning("Could not parse admonition title: %s", title_line) @@ -111,8 +122,10 @@ def extract_admonition_blocks( section_title = title_match.group(1) # Learning goal + # ((?:(?!-->).)+?) instead of (.+?) so the match can never run past + # the end of the comment into an adjacent one learning_goal_match = re.search( - r'', + r").)+?)\s*-->", body, re.DOTALL, ) @@ -120,34 +133,34 @@ def extract_admonition_blocks( learning_goal = normalize_whitespace(learning_goal_match.group(1)) else: learning_goal = "TODO" - validation_issues.append({ - 'section': section_title, - 'missing_fields': ['learning-goal'] - }) + validation_issues.append({"section": section_title, "missing_fields": ["learning-goal"]}) # Strip all START/END markers before parsing objectives - body_cleaned = re.sub(r'\s*', '', body) - body_cleaned = re.sub(r'\s*', '', body_cleaned) + body_cleaned = re.sub(r"\s*", "", body) + body_cleaned = re.sub(r"\s*", "", body_cleaned) - # Parse numbered objectives with optional inline metadata comment + # Parse numbered objectives with optional inline metadata comment. + # The comment group must not cross a --> boundary, and the lookahead + # accepts a following comment so a stray extra comment between + # objectives doesn't get swallowed into the objective text. objectives = [] - objective_pattern = r'\d+\.\s+(.+?)(?:(?:\n\s*|(?=)?(?=\n\d+\.|\n\n|$)' + objective_pattern = ( + r"\d+\.\s+(.+?)(?:(?:\n\s*|(?=).)+?)\s*-->)?(?=\n\d+\.|\n\s*