elemes/services/lesson_service.py

1641 lines
65 KiB
Python

"""
Lesson loading, ordering, and markdown rendering.
"""
import os
import re
import time
import html as html_module
import bleach
from functools import lru_cache, wraps
from threading import Lock
from urllib.parse import urlparse
import markdown as md
from config import CONTENT_DIR
from services.progress_status import parse_progress_status
# Generic file cache (path -> {content, mtime})
_file_cache = {} # {path: {'content': str, 'mtime': float}}
_file_cache_lock = Lock()
_markdown_cache = {}
_markdown_lock = Lock()
# Parsed sub-home data cache (folder -> {mtime, data}) — mtime-based, agar
# hasil parsing sub-home.md selalu segar bila file-nya berubah (bukan lru_cache
# yang bisa mengembalikan data basi sampai proses restart).
_sub_home_cache = {} # {folder_name: {'mtime': float, 'data': dict}}
_sub_home_cache_lock = Lock()
# Pre-computed absolute path for home.md (avoids repeated syscall on hot path)
_HOME_MD_PATH = os.path.abspath(os.path.join(CONTENT_DIR, "home.md"))
# TTL untuk cache DAFTAR lesson (hasil parsing home.md/sub-home.md).
# Produksi memakai gunicorn multi-worker: `invalidate_lesson_caches()` hanya
# membersihkan cache di worker yang menangani publish, sedangkan worker lain
# menyimpan lru_cache sendiri. TTL membuat worker tersebut menyegarkan diri
# tanpa restart (cukup pendek agar perubahan cepat terasa, cukup panjang agar
# tidak mem-parse ulang di setiap request). Di-override via env bila perlu.
_LESSON_LIST_CACHE_TTL_SECONDS = float(os.environ.get("LESSON_LIST_CACHE_TTL", "30"))
def _ttl_lru_cache(maxsize: int):
"""lru_cache + TTL, dengan API `.cache_clear()` / `.cache_info()` yang sama.
TTL dibaca ulang dari `_LESSON_LIST_CACHE_TTL_SECONDS` pada setiap call
(bisa di-monkeypatch di test). Aman untuk multi-worker karena tiap proses
punya cache sendiri dan menyegarkan diri setelah TTL — tidak bergantung
pada invalidasi in-process dari worker yang menangani publish.
"""
def decorator(func):
cached = lru_cache(maxsize=maxsize)(func)
state = {"cleared_at": time.monotonic()}
lock = Lock()
@wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
with lock:
if now - state["cleared_at"] >= _LESSON_LIST_CACHE_TTL_SECONDS:
cached.cache_clear()
state["cleared_at"] = now
return cached(*args, **kwargs)
def cache_clear():
with lock:
cached.cache_clear()
state["cleared_at"] = time.monotonic()
wrapper.cache_clear = cache_clear
wrapper.cache_info = cached.cache_info
return wrapper
return decorator
def _read_md_cached(path):
"""Read any markdown file with mtime-based caching.
Uses a single dict cache keyed by absolute path.
Returns empty string if file is missing or unreadable.
"""
if not os.path.exists(path):
return ""
try:
current_mtime = os.path.getmtime(path)
except OSError:
cached = _file_cache.get(path)
return (cached['content'] if cached else "") or ""
with _file_cache_lock:
cached = _file_cache.get(path)
if cached and cached['mtime'] == current_mtime:
return cached['content']
# Read outside the lock (no file I/O under lock)
try:
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
except (OSError, PermissionError) as e:
print(f"Warning: Could not read {path}: {e}")
cached = _file_cache.get(path)
return (cached['content'] if cached else "") or ""
with _file_cache_lock:
_file_cache[path] = {'content': content, 'mtime': current_mtime}
# If this is the root home.md or a sub-home.md, invalidate downstream caches
if os.path.abspath(path) == _HOME_MD_PATH or os.path.basename(path) == 'sub-home.md':
invalidate_lesson_caches(path)
return content
def invalidate_lesson_caches(file_path=None):
"""Invalidate all in-memory caches related to lessons, files, and navigation.
If file_path is provided, specific file entries in _markdown_cache and _file_cache
are removed. LRU caches and sub_home cache are cleared unconditionally.
"""
find_lesson_file.cache_clear()
get_lessons.cache_clear()
get_lesson_names.cache_clear()
get_lessons_with_learning_objectives.cache_clear()
with _markdown_lock:
if file_path:
_markdown_cache.pop(file_path, None)
_markdown_cache.pop(os.path.abspath(file_path), None)
else:
_markdown_cache.clear()
with _file_cache_lock:
if file_path:
_file_cache.pop(file_path, None)
_file_cache.pop(os.path.abspath(file_path), None)
else:
_file_cache.clear()
with _sub_home_cache_lock:
_sub_home_cache.clear()
def _read_home_md():
"""Read root home.md — thin wrapper for backwards compatibility."""
path = os.path.join(CONTENT_DIR, "home.md")
return _read_md_cached(path)
def _parse_lesson_links(home_content):
"""Extract (link_text, filename) pairs from the Available_Lessons section.
Skips sub-home.md entries so they don't appear as lessons.
"""
parts = re.split(r'-{3,}Available_Lessons-{3,}', home_content)
if len(parts) <= 1:
return []
lesson_list_content = parts[-1]
# Allow optional leading slash in /lesson/ prefix
links = re.findall(r'\[([^\]]+)\]\((?:/?lesson/)?([^\)]+)\)', lesson_list_content)
processed_links = []
for title, slug in links:
filename = slug if slug.endswith('.md') else slug + '.md'
# Skip sub-home.md — it's not a lesson
if filename == 'sub-home.md':
continue
processed_links.append((title, filename))
return processed_links
# ---------------------------------------------------------------------------
# Lesson listing
# ---------------------------------------------------------------------------
@_ttl_lru_cache(maxsize=128)
def find_lesson_file(filename):
"""Recursively search for filename in CONTENT_DIR and return its full path."""
# Security: Prevent directory traversal
if '/' in filename or '\\' in filename:
return None
# Skip sub-home.md — it's not a lesson
if filename == 'sub-home.md':
return None
for root, _, files in os.walk(CONTENT_DIR):
if filename in files:
return os.path.join(root, filename)
return None
@_ttl_lru_cache(maxsize=32)
def get_lessons(source_path=None):
"""Get lessons from the Available_Lessons section in home.md.
`source_path` opsional: bila diberikan, daftar materi diambil dari file
markdown tersebut (mis. sub-home.md) alih-alih home.md root.
"""
lessons = []
source_content = _read_md_cached(source_path) if source_path else _read_home_md()
if not source_content:
return lessons
for link_text, filename in _parse_lesson_links(source_content):
file_path = find_lesson_file(filename)
if not file_path:
continue
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
title = link_text
description = "Learn C programming concepts with practical examples."
for i, line in enumerate(lines):
if line.startswith('# ') and title == link_text:
if title == "Untitled" or title == link_text:
title = line[2:].strip()
elif title != "Untitled" and line.strip() != "" and not line.startswith('#') and i < 10:
clean_line = line.strip().replace('#', '').strip()
if len(clean_line) > 10:
description = clean_line
break
lessons.append({
'filename': filename,
'title': title,
'description': description,
'path': file_path,
})
return lessons
@_ttl_lru_cache(maxsize=32)
def get_lesson_names():
"""Get lesson names (without .md extension) from Available_Lessons."""
home_content = _read_home_md()
if not home_content:
return []
names = []
for _link_text, filename in _parse_lesson_links(home_content):
file_path = find_lesson_file(filename)
if file_path:
names.append(filename.replace('.md', ''))
return names
# ---------------------------------------------------------------------------
# Prerequisite helpers
# ---------------------------------------------------------------------------
_MIN_PERCENT_RE = re.compile(r'\bmin(?:imum)?\s*:?\s*(\d+(?:[.,]\d+)?)\s*%')
def _parse_prerequisite_bullet(bullet):
"""Parse satu bullet prasyarat → (slug_or_title, min_percent) | None.
Mendukung format:
- [Kuis](lesson/quiz.md) → ("quiz", None)
- [Kuis](lesson/quiz.md) min: 75% → ("quiz", None)
- Kuis min 75% → ("Kuis", None)
- "Tidak ada" / "None" / "-" → None (difilter)
NOTE: Suffix `min: N%` bersifat DEPRECATED — global LESSON_DONE_MIN_PERCENT
(config.py) adalah satu-satunya sumber ambang sekarang. Suffix tetap
dibuang dari teks agar file materi lama dengan sintaks tersebut tetap
ter-parse, TAPI `min_percent` SELALU dikembalikan sebagai None (tidak
lagi diekstrak/dipergunakan).
"""
bullet = bullet.strip()
# Terima juga bentuk dengan dash awal (mis. hasil split manual) — di
# produksi bullet biasanya sudah dipotong dash oleh regex `- ([^\n]+)`.
if bullet.startswith('-'):
bullet = bullet[1:].strip()
if bullet.lower() in ('tidak ada', 'none', '-', ''):
return None
# `min: N%` suffix dibuang dari teks (kompatibilitas mundur) tetapi
# tidak lagi diekstrak — ambang per-lesson di-deprecate.
min_match = _MIN_PERCENT_RE.search(bullet)
if min_match:
bullet = bullet[:min_match.start()].strip().rstrip(',').strip()
min_percent = None
# Check if it's a markdown link format [title](path)
md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet)
if md_link_match:
# Extract slug from the link path
link_path = md_link_match.group(2)
# Handle paths like lesson/hello_world.md or just hello_world.md
slug = link_path.replace('.md', '').split('/')[-1]
return slug, min_percent
# Plain text - keep as title for later resolution
return bullet, min_percent
def is_prerequisite_met(progress, prereq) -> bool:
"""True bila prasyarat terpenuhi berdasarkan status progress siswa.
`prereq` berupa slug string, atau dict {'slug': ..., 'min_percent': ...} —
field `min_percent` diabaikan (DEPRECATED); ambang global
LESSON_DONE_MIN_PERCENT (config.py) adalah satu-satunya sumber "done".
Kontrak baru (threshold GLOBAL): sebuah prasyarat terpenuhi iff status
progress pendahulu BUKAN kosong DAN BUKAN 'not_started':
- "" / "not_started" → belum terpenuhi
- "completed" → terpenuhi
- "<earned>/<total>" (scored) → terpenuhi (threshold per-lesson diabaikan)
- status tidak dikenal → belum terpenuhi
Status "done" di sisi backend (state composite >= LESSON_DONE_MIN_PERCENT)
dirender oleh format_progress_status sebagai string non-kosong, sehingga
pemeriksaan ini cukup: status non-kosong & bukan 'not_started' = terpenuhi.
"""
if isinstance(prereq, str):
slug = prereq
else:
# min_percent diabaikan (DEPRECATED) — hanya slug yang dipakai.
slug = prereq['slug']
if not progress:
return False
status = progress.get(slug, '')
if status is None:
return False
status = str(status).strip()
if not status:
return False
return status != 'not_started'
def get_missing_prerequisites(progress, prereqs):
"""Slug prasyarat yang belum terpenuhi, urut sesuai deklarasi materi.
Menerima daftar slug string ATAU dict {'slug', 'min_percent'} — kompatibel
dengan `lesson['prerequisites']` (legacy) dan `lesson['prerequisite_specs']`.
"""
missing = []
for prereq in prereqs or []:
if not is_prerequisite_met(progress, prereq):
missing.append(prereq['slug'] if isinstance(prereq, dict) else prereq)
return missing
def _build_lessons_with_objectives(lesson_links):
"""Build enriched lesson dicts (title, description, prerequisite_titles)
from parsed (link_text, filename) pairs, reading each lesson file."""
lessons = []
for link_text, filename in lesson_links:
file_path = find_lesson_file(filename)
if not file_path:
continue
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
title = link_text
description = "Learn C programming concepts with practical examples."
lesson_info_start = content.find('---LESSON_INFO---')
lesson_info_end = content.find('---END_LESSON_INFO---')
prerequisite_titles = []
prerequisite_specs = []
if lesson_info_start != -1 and lesson_info_end != -1:
lesson_info_section = content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end]
# Extract Learning Objectives
objectives_start = lesson_info_section.find('**Learning Objectives:**')
if objectives_start != -1:
objectives_section = lesson_info_section[objectives_start:]
objective_matches = re.findall(r'- ([^\n]+)', objectives_section)
if objective_matches:
description = '; '.join(objective_matches[:3])
else:
lines_after = lesson_info_section[objectives_start:].split('\n')[1:4]
description = ' '.join(line.strip() for line in lines_after if line.strip())
# Extract Prerequisites
prereq_start = lesson_info_section.find('**Prerequisites:**')
if prereq_start != -1:
prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):]
bullet_lines = re.findall(r'- ([^\n]+)', prereq_section)
prerequisite_slugs = []
prerequisite_specs = []
for bullet in bullet_lines:
parsed = _parse_prerequisite_bullet(bullet)
if parsed is None:
continue
slug_or_title, min_percent = parsed
prerequisite_slugs.append(slug_or_title)
prerequisite_specs.append({'slug': slug_or_title, 'min_percent': min_percent})
prerequisite_titles = prerequisite_slugs
content_after_info = content[lesson_info_end + len('---END_LESSON_INFO---'):].strip()
for line in content_after_info.split('\n'):
if line.startswith('# '):
title = line[2:].strip()
break
else:
lines = content.split('\n')
for line in lines:
if line.startswith('# ') and title == link_text:
if title == "Untitled" or title == link_text:
title = line[2:].strip()
break
lessons.append({
'filename': filename,
'title': title,
'description': description,
'path': file_path,
'prerequisite_titles': prerequisite_titles,
'prerequisite_specs': prerequisite_specs,
})
return lessons
@_ttl_lru_cache(maxsize=32)
def get_lessons_with_learning_objectives():
"""Get lessons with learning objectives extracted from LESSON_INFO sections."""
home_content = _read_home_md()
if not home_content:
return []
return _build_lessons_with_objectives(_parse_lesson_links(home_content))
def get_ordered_lessons_with_learning_objectives(progress=None, source_path=None):
"""Get lessons ordered per home.md with completion status from progress dict.
`source_path` opsional: bila diberikan (mis. path ke sub-home.md), daftar
materi & urutannya diambil dari file tersebut, bukan dari home.md root.
"""
if source_path:
content = _read_md_cached(source_path)
lesson_links = _parse_lesson_links(content) if content else []
all_lessons = _build_lessons_with_objectives(lesson_links)
else:
home_content = _read_home_md()
lesson_links = _parse_lesson_links(home_content) if home_content else []
all_lessons = get_lessons_with_learning_objectives()
# Build title -> slug mapping for prerequisite resolution
title_to_slug = {lesson['title']: lesson['filename'].replace('.md', '') for lesson in all_lessons}
# Also map link text from home.md
for link_text, filename in lesson_links:
title_to_slug[link_text] = filename.replace('.md', '')
def _add_completion_and_prereqs(lesson, progress):
slug = lesson['filename'].replace('.md', '')
if progress:
status = progress.get(slug, '')
lesson['completed'] = status not in (None, '', 'not_started')
else:
lesson['completed'] = False
# Resolve prerequisites - now contains slugs directly from markdown links
# or still contains plain text titles that need resolution
items = lesson.get('prerequisite_titles', [])
specs = lesson.get('prerequisite_specs') or [
{'slug': item, 'min_percent': None} for item in items
]
resolved_prereqs = []
resolved_specs = []
for i, item in enumerate(items):
# If it's already a valid slug (exists in all_lessons), use it directly
if item in title_to_slug.values():
slug = item
# Otherwise try to resolve via title mapping
elif item in title_to_slug:
slug = title_to_slug[item]
else:
continue
resolved_prereqs.append(slug)
spec = specs[i] if i < len(specs) else {}
resolved_specs.append({'slug': slug, 'min_percent': spec.get('min_percent')})
lesson['prerequisites'] = resolved_prereqs
lesson['prerequisite_specs'] = resolved_specs
return lesson
if lesson_links:
ordered = []
for link_text, filename in lesson_links:
for lesson in all_lessons:
if lesson['filename'] == filename:
copy = lesson.copy()
copy['title'] = link_text
_add_completion_and_prereqs(copy, progress)
ordered.append(copy)
break
seen = {l['filename'] for l in ordered}
for lesson in all_lessons:
if lesson['filename'] not in seen:
copy = lesson.copy()
_add_completion_and_prereqs(copy, progress)
ordered.append(copy)
return ordered
ordered_fallback = []
for lesson in all_lessons:
copy = lesson.copy()
_add_completion_and_prereqs(copy, progress)
ordered_fallback.append(copy)
return ordered_fallback
# ---------------------------------------------------------------------------
# Sub-Home helpers
# ---------------------------------------------------------------------------
def find_sub_home_for_lesson(file_path):
"""Find sub-home.md in the same folder as file_path, or None.
Returns (sub_home_path, folder_name) or (None, None).
Handles PermissionError gracefully.
"""
if not file_path:
return None, None
folder = os.path.dirname(file_path)
sub_home_path = os.path.join(folder, 'sub-home.md')
if not os.path.exists(sub_home_path):
return None, None
# Permission check
try:
if not os.access(sub_home_path, os.R_OK):
print(f"Warning: Cannot read {sub_home_path} (permission denied)")
return None, None
except OSError:
return None, None
folder_name = os.path.basename(folder)
return sub_home_path, folder_name
def get_sub_home_path(folder_name):
"""Absolute path to a folder's sub-home.md, or None if missing.
Uses the same CONTENT_DIR as get_sub_home_data() so callers can feed the
result back into get_ordered_lessons_with_learning_objectives(source_path=...)
without re-deriving the path (and without mismatched CONTENT_DIR copies).
"""
folder_path = os.path.join(CONTENT_DIR, folder_name)
sub_home_path = os.path.join(folder_path, 'sub-home.md')
if os.path.isdir(folder_path) and os.path.exists(sub_home_path):
return sub_home_path
return None
def get_sub_home_data(folder_name):
"""Return parsed sub-home data for a given folder name (mtime-cached).
Returns dict with keys: title, intro_html, lessons, folder, url.
Returns None if no sub-home.md found or unreadable.
"""
folder_path = os.path.join(CONTENT_DIR, folder_name)
if not os.path.isdir(folder_path):
return None
sub_home_path = os.path.join(folder_path, 'sub-home.md')
if not os.path.exists(sub_home_path):
return None
# Permission check
try:
if not os.access(sub_home_path, os.R_OK):
print(f"Warning: Cannot read {sub_home_path} (permission denied)")
return None
except OSError:
return None
# mtime-based cache: refresh bila sub-home.md diubah, tanpa menunggu restart
try:
current_mtime = os.path.getmtime(sub_home_path)
except OSError:
current_mtime = 0.0
with _sub_home_cache_lock:
cached = _sub_home_cache.get(folder_name)
if cached and cached['mtime'] == current_mtime:
return cached['data']
content = _read_md_cached(sub_home_path)
if not content:
return None
# Extract intro (before Available_Lessons)
parts = re.split(r'-{3,}Available_Lessons-{3,}', content)
intro_raw = parts[0] if parts else content
# Remove heading from intro if present (it becomes the title)
title = folder_name.replace('_', ' ').title()
intro_lines = intro_raw.strip().split('\n')
if intro_lines and intro_lines[0].startswith('# '):
title = intro_lines[0][2:].strip()
intro_raw = '\n'.join(intro_lines[1:])
intro_html = md.markdown(intro_raw, extensions=['fenced_code', 'tables', 'mdx_math']) if intro_raw.strip() else ''
# Parse lesson links from Available_Lessons
lesson_links = _parse_lesson_links(content)
lessons = []
for link_text, filename in lesson_links:
file_path = find_lesson_file(filename)
if not file_path:
continue
try:
with open(file_path, 'r', encoding='utf-8') as f:
lesson_content = f.read()
except (OSError, PermissionError):
continue
lesson_title = link_text
description = "Learn C programming concepts with practical examples."
prerequisite_titles = []
prerequisite_specs = []
lesson_info_start = lesson_content.find('---LESSON_INFO---')
lesson_info_end = lesson_content.find('---END_LESSON_INFO---')
if lesson_info_start != -1 and lesson_info_end != -1:
lesson_info_section = lesson_content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end]
objectives_start = lesson_info_section.find('**Learning Objectives:**')
if objectives_start != -1:
objectives_section = lesson_info_section[objectives_start:]
objective_matches = re.findall(r'- ([^\n]+)', objectives_section)
if objective_matches:
description = '; '.join(objective_matches[:3])
# Extract Prerequisites
prereq_start = lesson_info_section.find('**Prerequisites:**')
if prereq_start != -1:
prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):]
bullet_lines = re.findall(r'- ([^\n]+)', prereq_section)
for bullet in bullet_lines:
parsed = _parse_prerequisite_bullet(bullet)
if parsed is None:
continue
slug_or_title, min_percent = parsed
prerequisite_titles.append(slug_or_title)
prerequisite_specs.append({'slug': slug_or_title, 'min_percent': min_percent})
else:
for line in lesson_content.split('\n')[:10]:
if line.startswith('# '):
lesson_title = line[2:].strip()
break
lessons.append({
'filename': filename,
'title': lesson_title,
'description': description,
'path': file_path,
'prerequisite_titles': prerequisite_titles,
'prerequisite_specs': prerequisite_specs,
})
data = {
'title': title,
'intro_html': intro_html,
'lessons': lessons,
'folder': folder_name,
'url': f'/bab/{folder_name}',
}
with _sub_home_cache_lock:
if len(_sub_home_cache) >= 64:
_sub_home_cache.clear()
_sub_home_cache[folder_name] = {'mtime': current_mtime, 'data': data}
return data
def find_all_sub_home_folders():
"""Return list nama folder level-1 di CONTENT_DIR yang berisi sub-home.md.
Digunakan lesson_registry.py untuk menemukan semua materi sub-bab agar
ter-sync ke tabel `lessons` (bukan cuma home.md root). Hanya memindai
langsung satu level di bawah CONTENT_DIR — sub-bab Elemes memang strukturnya
flat (bab/<folder>/sub-home.md).
"""
folders = []
if not os.path.isdir(CONTENT_DIR):
return folders
try:
for entry in os.scandir(CONTENT_DIR):
if entry.is_dir(follow_symlinks=False) and os.path.exists(
os.path.join(entry.path, 'sub-home.md')
):
folders.append(entry.name)
except OSError:
pass
return folders
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc', 'mdx_math']
# Domain blacklist for embed iframe src (must be https).
EMBED_BLOCKED_HOSTS = {
'localhost', '127.0.0.1', '0.0.0.0',
'metadata.google.internal', '169.254.169.254',
}
# HTML sanitization config for ```embed fences (raw HTML embed code)
EMBED_ALLOWED_TAGS = ['div', 'iframe', 'a', 'span', 'p', 'br', 'img']
EMBED_ALLOWED_ATTRS = {
'div': ['style', 'class'],
'iframe': ['src', 'style', 'loading', 'allowfullscreen', 'allow', 'title', 'class'],
'a': ['href', 'target', 'rel', 'style', 'class'],
'span': ['style', 'class'],
'p': ['style', 'class'],
'img': ['src', 'alt', 'style', 'class', 'loading'],
'*': ['class'],
}
EMBED_ALLOWED_STYLES = [
'position', 'width', 'height', 'padding', 'padding-top', 'padding-bottom',
'padding-left', 'padding-right', 'margin', 'margin-top', 'margin-bottom',
'margin-left', 'margin-right', 'border', 'border-radius',
'overflow', 'box-shadow', 'top', 'left', 'right', 'bottom',
'will-change', 'display', 'flex-direction', 'gap',
'max-width', 'max-height', 'min-height',
]
def _process_circuit_embeds(text):
"""Replace ```circuit[,width][,height] code fences with embeddable HTML divs.
Supported formats:
```circuit -> width=100%, height=400px
```circuit,500px -> width=100%, height=500px
```circuit,80%,500px -> width=80%, height=500px
"""
pattern = re.compile(
r'```circuit(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```',
re.DOTALL,
)
def _replacer(match):
param1 = match.group(1)
param2 = match.group(2)
# One param = height only; two params = width, height
if param1 and param2:
width, height = param1, param2
elif param1:
width, height = '100%', param1
else:
width, height = '100%', '400px'
data = html_module.escape(match.group(3).strip())
return (
f'<div class="circuit-embed" '
f'data-width="{html_module.escape(width)}" '
f'data-height="{html_module.escape(height)}">'
f'<pre class="circuit-data" style="display:none">{data}</pre>'
f'<div class="circuit-embed-loading">Memuat simulator...</div>'
f'</div>'
)
return pattern.sub(_replacer, text)
def _process_flowchart_embeds(text):
"""Replace ```flowchart[,width][,height] code fences with embeddable HTML divs.
Supported formats:
```flowchart -> width=100%, height=400px
```flowchart,500px -> width=100%, height=500px
```flowchart,80%,500px -> width=80%, height=500px
"""
pattern = re.compile(
r'```flowchart(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```',
re.DOTALL,
)
def _replacer(match):
param1 = match.group(1)
param2 = match.group(2)
# One param = height only; two params = width, height
if param1 and param2:
width, height = param1, param2
elif param1:
width, height = '100%', param1
else:
width, height = '100%', '400px'
data = html_module.escape(match.group(3).strip())
return (
f'<div class="flowchart-embed" '
f'data-width="{html_module.escape(width)}" '
f'data-height="{html_module.escape(height)}">'
f'<pre class="flowchart-data" style="display:none">{data}</pre>'
f'<div class="flowchart-embed-loading">Memuat flowchart...</div>'
f'</div>'
)
return pattern.sub(_replacer, text)
def _sanitize_embed_html(html_text):
"""Sanitize raw embed HTML: whitelist tags/attrs/styles + check iframe src domain."""
# CSS sanitization requires tinycss2 — bila tidak ada, style dipertahankan
# tanpa disanitasi (tags/attrs tetap di-strip). Jangan pernah pass
# css_sanitizer=None bersamaan dengan atribut `style` — bleach mengeluarkan
# NoCssSanitizerWarning.
try:
from bleach.css_sanitizer import CSSSanitizer
css_sanitizer = CSSSanitizer(allowed_css_properties=EMBED_ALLOWED_STYLES)
except ImportError:
css_sanitizer = None
kwargs = dict(tags=EMBED_ALLOWED_TAGS, attributes=EMBED_ALLOWED_ATTRS, strip=True)
if css_sanitizer is not None:
kwargs["css_sanitizer"] = css_sanitizer
cleaned = bleach.clean(html_text, **kwargs)
# Check every iframe src: must be https + not blacklisted
for match in re.finditer(r'<iframe[^>]+src="([^"]*)"', cleaned):
src = match.group(1)
try:
host = (urlparse(src).hostname or '').lower()
except Exception:
return '<div class="embed-error">Konten embed ditolak: URL iframe tidak valid.</div>'
if not src.startswith('https://'):
return '<div class="embed-error">Konten embed ditolak: iframe harus https.</div>'
if host in EMBED_BLOCKED_HOSTS or any(host.endswith('.' + h) for h in EMBED_BLOCKED_HOSTS):
return '<div class="embed-error">Konten embed ditolak: domain iframe diblokir.</div>'
return cleaned
def _process_embed_embeds(text):
"""Replace ```embed fences containing raw HTML embed code with sanitized HTML.
User pastes embed code from Canva/YouTube/Google Docs (Share → Embed).
HTML is sanitized via bleach (whitelist tags/attrs/styles) and iframe src
is checked against EMBED_BLOCKED_HOSTS.
"""
pattern = re.compile(
r'```embed\s*\n(.*?)```',
re.DOTALL,
)
def _replacer(match):
raw_html = match.group(1).strip()
if not raw_html:
return '<div class="embed-error">Konten embed kosong.</div>'
return _sanitize_embed_html(raw_html)
return pattern.sub(_replacer, text)
# Regex opsi LEGACY (key ---QUIZ_FLASHCARD--- lama).
# Hanya mengenali '- [x]' / '- [X]' / '- [ ]' / '- []'.
# Sengaja TIDAK mengenali '- [10]' supaya konten lama berperilaku persis sama
# (baris '- [10]' bukan opsi — ia tetap bagian dari body soal).
_OPTION_LINE_RE = re.compile(r'^\s*-\s*\[([ xX]?)\]\s*(.*)$')
# Regex opsi KEY BARU (---QUIZ_MCQ--- / ---QUIZ_WEIGHTED--- / ---FLASHCARD---).
# group(1) = mark ('x'/'X'/' '/''), group(2) = bobot angka opsional (weighted).
_WEIGHTED_OPTION_LINE_RE = re.compile(r'^\s*-\s*\[([ xX]?)(\d*)\]\s*(.*)$')
# Nama marker key quiz + jenis parser-nya (mendukung beberapa blok per key).
# kind 'legacy' → inspeksi konten (opsi → mcq, tanpa opsi → flashcard)
# kind 'mcq' → MCQ biner, tepat satu [x]
# kind 'weighted'→ MCQ berbobot, [N] dengan N >= 1
# kind 'flashcard'→ tanpa opsi sama sekali
QUIZ_SECTION_SPECS = (
('legacy', '---QUIZ_FLASHCARD---', '---END_QUIZ_FLASHCARD---'),
('mcq', '---QUIZ_MCQ---', '---END_QUIZ_MCQ---'),
('weighted', '---QUIZ_WEIGHTED---', '---END_QUIZ_WEIGHTED---'),
('flashcard', '---FLASHCARD---', '---END_FLASHCARD---'),
)
# Semua marker yang mengaktifkan tab 'quiz' (legacy tetap dibaca).
QUIZ_TAB_MARKERS = (
'---INITIAL_QUIZ---',
'---QUIZ_FLASHCARD---',
'---QUIZ_MCQ---',
'---QUIZ_WEIGHTED---',
'---FLASHCARD---',
)
def _iter_option_lines(body, kind='legacy'):
"""Yield (char_index, mark, weight_str, content) for option lines outside fenced code.
Lines inside ``` fenced blocks are skipped so code examples containing
'- [x] ...' are never mistaken for real options.
`kind='legacy'` memakai regex lama (tanpa bobot angka) supaya konten
legacy berperilaku 100% sama seperti sebelumnya. Key baru memakai regex
yang juga mengenali bobot angka ('- [10]').
"""
in_fence = False
pos = 0
for line in body.split('\n'):
if line.strip().startswith('```'):
in_fence = not in_fence
elif not in_fence:
if kind == 'legacy':
m = _OPTION_LINE_RE.match(line)
if m:
yield pos, m.group(1), '', m.group(2)
else:
m = _WEIGHTED_OPTION_LINE_RE.match(line)
if m:
yield pos, m.group(1), m.group(2), m.group(3)
pos += len(line) + 1
def _extract_body_image(text):
"""Return (image_url, cleaned_text) for the first markdown image found
outside fenced code blocks. cleaned_text has that image markup removed.
Returns (None, text) when no image is found.
"""
lines = text.split('\n')
fence = False
for idx, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith('```'):
fence = not fence
continue
if fence:
continue
m = re.search(r'!\[[^\]]*\]\(([^)]+)\)', line)
if m:
url = m.group(1).strip()
cleaned_line = line[:m.start()] + line[m.end():]
cleaned_lines = lines[:idx] + ([cleaned_line] if cleaned_line.strip() else []) + lines[idx + 1:]
return url, '\n'.join(cleaned_lines).strip()
return None, text
def _split_flashcard_parts(text):
"""Split quiz markdown into per-question blocks on heading lines
(`^#{1,3}\\s+`), but ignore heading-looking lines that live INSIDE a
fenced code block (``` ... ```).
Re-implementasi dari `re.split(r'^#{1,3}\\s+', text, MULTILINE)` yang lama
agar komentar `#` di dalam kode (python/shell/bash/yaml) tidak membelah
soal. Penggunaan state-machine per-baris, bukan placeholder, supaya tidak
ada risiko tabrakan string.
"""
parts: list[str] = []
buf: list[str] = []
in_fence = False
for line in text.split('\n'):
stripped = line.strip()
# Toggle fence state pada baris yang HANYA berisi ``` (atau ```lang)
if stripped.startswith('```'):
in_fence = not in_fence
buf.append(line)
continue
# Heading pembatas soal — hanya di luar fence
if not in_fence and re.match(r'^#{1,3}\s+', line):
if buf:
parts.append('\n'.join(buf))
buf = [line]
continue
buf.append(line)
if buf:
parts.append('\n'.join(buf))
# Baris pertama biasanya kosong (teks sebelum heading `###` pertama)
return [p for p in parts if p.strip()]
def _validate_quiz_options(kind, question_no, marks, weights):
"""Validasi ketat opsi per jenis key. Return (is_correct_flags, weights).
`marks` = list mark mentah ('x'/'X'/' '/'')
`weights`= list bobot mentah (int atau None bila option tanpa angka)
kind:
- 'legacy' : tepat satu [x]; bobot diturunkan (benar=1, salah=0)
- 'mcq' : tepat satu [x]; DILARANG memakai bobot angka ([N])
- 'weighted': DILARANG [x]; bobot integer >= 1; minimal satu opsi tanpa
bobot ([ ]); bobot tertinggi harus unik
- 'flashcard': opsi jawaban DILARANG
"""
n = len(marks)
if kind == 'flashcard':
raise ValueError(
f"Soal kuis ke-{question_no} di FLASHCARD tidak boleh punya opsi jawaban; "
f"pindahkan soal pilihan ganda ke key QUIZ_MCQ atau QUIZ_WEIGHTED."
)
if kind in ('legacy', 'mcq'):
if kind == 'mcq' and any(w is not None for w in weights):
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_MCQ tidak boleh memakai bobot angka "
f"([N]); gunakan [x] untuk jawaban benar."
)
correct_count = sum(1 for m in marks if m.lower() == 'x')
if correct_count != 1:
raise ValueError(
f"Soal kuis ke-{question_no} wajib punya tepat satu opsi benar "
f"(ditemukan {correct_count})."
)
is_correct = [m.lower() == 'x' for m in marks]
final_weights = [1 if ok else 0 for ok in is_correct]
return is_correct, final_weights
# kind == 'weighted'
if any(m.lower() == 'x' for m in marks):
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_WEIGHTED tidak boleh memakai penanda "
f"[x]; gunakan bobot angka ([N]) atau [ ] untuk jawaban salah."
)
if any(w is not None and w < 1 for w in weights):
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_WEIGHTED: bobot harus berupa bilangan "
f"bulat >= 1."
)
if not any(w is None for w in weights):
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_WEIGHTED wajib punya minimal satu opsi "
f"tanpa bobot ([ ]) sebagai jawaban salah."
)
numeric = [w for w in weights if w is not None]
if numeric:
max_weight = max(numeric)
top = sum(1 for w in weights if w == max_weight)
if top > 1:
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_WEIGHTED: bobot tertinggi harus unik "
f"(ditemukan {top} opsi dengan bobot {max_weight})."
)
is_correct = [w == max_weight for w in weights]
else:
is_correct = [False] * n
final_weights = [0 if w is None else w for w in weights]
return is_correct, final_weights
def _parse_flashcards(text, kind='legacy', id_offset=0):
"""Parse a string of markdown with headings and options into a list of dicts.
`kind` menentukan model evaluasi & validasi per jenis key:
- 'legacy' → key lama ---QUIZ_FLASHCARD---: jenis implisit via
inspeksi konten (ada opsi → MCQ, tanpa opsi → flashcard).
Perilaku 100% sama seperti versi sebelum pemisahan key.
- 'mcq' → key ---QUIZ_MCQ---: wajib opsi, tepat satu [x].
- 'weighted' → key ---QUIZ_WEIGHTED---: bobot [N] (N >= 1), minimal satu
opsi [ ], bobot tertinggi unik.
- 'flashcard'→ key ---FLASHCARD---: tanpa opsi jawaban.
Format yang didukung:
1. Simple Flashcard: '### Question\nAnswer'
2. Multiple Choice (MCQ):
'### Question
[optional rich markdown body: paragraphs, fenced code, tables, math]
- [] option 1
- [x] option 2 (Correct)
- [] option 3
> Explanation'
Every question and option receives a stable id based on original parse
order ('q-0', 'q-0-o-0', ...) — the frontend uses these ids for shuffling
and scoring, so they must never depend on display order. `id_offset`
dipakai saat beberapa blok key quiz digabung agar id tetap unik & berurutan.
Setiap soal juga mendapat field `quiz_type` ('mcq' | 'weighted' | 'flashcard')
supaya konsumen (sesi kuis, laporan guru) tahu model evaluasinya tanpa
perlu menebak dari isi konten.
"""
if not text.strip():
return []
# Split by headings starting with #, ##, or ### — but ONLY outside fenced
# code blocks. A line like `# ini komentar` inside ```python must NOT be
# treated as a new question (it would split the question and steal its
# options, producing phantom questions / mismatched choices).
parts = _split_flashcard_parts(text)
flashcards = []
for part in parts:
if not part.strip():
continue
# First line is the question (Front)
subparts = part.split('\n', 1)
question = subparts[0].strip()
body = subparts[1] if len(subparts) > 1 else ""
if not question:
continue
# Initialize image_url - will be set from markdown image or 'image:' directive
image_url = ""
# Extract image from markdown syntax ![...](path) in question
md_image_match = re.search(r'!\[.*?\]\(([^)]+)\)', question)
if md_image_match:
md_image_path = md_image_match.group(1).strip()
# Jika path lokal (/assets/ atau bare filename) dan belum ada image dari 'image:' directive
if md_image_path.startswith('/assets/') or (not md_image_path.startswith(('http://', 'https://')) and not image_url):
image_url = md_image_path
# Check for image: URL
image_match = re.search(r'^\s*image:\s*(.*)$', body, re.MULTILINE)
if image_match:
image_url = image_match.group(1).strip() # Override jika ada 'image:' directive
if image_url.startswith('/assets/'):
# Path sudah lengkap, tidak perlu konversi
pass
elif not image_url.startswith(('http://', 'https://', '/')):
image_url = f'/assets/{image_url}'
body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip()
# Check for ::diagnostic marker (stripped from display, sets category)
category = 'evaluasi'
if re.search(r'^\s*::diagnostic\s*$', body, re.MULTILINE):
category = 'diagnostik'
body = re.sub(r'^\s*::diagnostic\s*$', '', body, flags=re.MULTILINE).strip()
# Option lines outside fenced code, in original order
option_lines = list(_iter_option_lines(body, kind))
first_option_idx = option_lines[0][0] if option_lines else len(body)
# Explanation blockquote: after the options for MCQ, anywhere for flashcard
explanation_search_region = body[first_option_idx:] if option_lines else body
explanation_match = re.search(r'^\s*(>.*)$', explanation_search_region, re.MULTILINE | re.DOTALL)
explanation = explanation_match.group(1).strip() if explanation_match else ""
q_id = f'q-{id_offset + len(flashcards)}'
question_no = len(flashcards) + 1
# If MCQ options exist, it's an MCQ.
if option_lines:
marks = [mark for _, mark, _, _ in option_lines]
raw_weights = [
(int(weight_str) if weight_str else None) for _, _, weight_str, _ in option_lines
]
is_correct_flags, final_weights = _validate_quiz_options(
kind, question_no, marks, raw_weights
)
parsed_options = []
for idx, (_, _mark, _weight_str, content) in enumerate(option_lines):
parsed_options.append({
'id': f'{q_id}-o-{len(parsed_options)}',
'text': md.markdown(content.strip(), extensions=MD_EXTENSIONS),
'is_correct': is_correct_flags[idx],
'weight': final_weights[idx]
})
# Rich question prompt: heading + everything before the first option
# line (paragraphs, fenced code, tables, math), through the same
# embed pipeline used for lesson material.
prompt_body = body[:first_option_idx].strip()
# Extract markdown image from the prompt body (outside code fences)
# if no image was found in the heading / image: directive yet, and
# drop it from the prompt so it is not rendered twice.
if not image_url:
extracted_url, prompt_body = _extract_body_image(prompt_body)
if extracted_url:
image_url = extracted_url
prompt_text = f"{question}\n\n{prompt_body}" if prompt_body else question
prompt_text = _process_circuit_embeds(prompt_text)
prompt_text = _process_flowchart_embeds(prompt_text)
prompt_text = _process_embed_embeds(prompt_text)
flashcards.append({
'id': q_id,
'type': 'mcq',
'quiz_type': 'weighted' if kind == 'weighted' else 'mcq',
'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS),
'options': parsed_options,
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
'image': image_url,
'category': category
})
else:
# Tanpa opsi → flashcard. Pada key QUIZ_MCQ / QUIZ_WEIGHTED ini
# tidak valid (wajib ada opsi jawaban); pada key FLASHCARD & legacy
# justru merupakan bentuk normal.
if kind == 'mcq':
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_MCQ wajib punya opsi jawaban."
)
if kind == 'weighted':
raise ValueError(
f"Soal kuis ke-{question_no} di QUIZ_WEIGHTED wajib punya opsi jawaban."
)
# It's a simple Flashcard
# Remove explanation from body if it's there to keep 'back' clean
clean_back = body
if explanation_match:
clean_back = body[:explanation_match.start()].strip()
flashcards.append({
'id': q_id,
'type': 'flashcard',
'quiz_type': 'flashcard',
'front': md.markdown(question, extensions=MD_EXTENSIONS),
'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
'image': image_url,
'options': None,
'category': category
})
return flashcards
def _find_marker_index(content, marker, from_index=0):
"""Index marker di `content` — utamakan marker yang berdiri sendiri di baris.
Mirror persis `findMarkerIndex` di frontend (extractSections.ts) supaya
ekstraksi section backend & frontend menghasilkan konten yang sama.
Fallback ke kemunculan pertama bila marker tidak berdiri sendiri (mis.
disebut di dalam prosa).
"""
idx = content.find(marker, from_index)
fallback_idx = -1
while idx != -1:
prev_newline = content.rfind('\n', 0, idx)
line_prefix = content[prev_newline + 1:idx]
if line_prefix.strip() == '':
return idx
if fallback_idx == -1:
fallback_idx = idx
idx = content.find(marker, idx + len(marker))
return fallback_idx
def _extract_quiz_section(content, start_marker, end_marker):
"""Ekstrak satu blok key quiz — (extracted, remaining_content).
Semantik sama dengan `extractSection` frontend: marker end dicari SETELAH
marker start, dan marker yang berdiri sendiri di baris diutamakan.
"""
if start_marker not in content or end_marker not in content:
return "", content
start_idx = _find_marker_index(content, start_marker)
if start_idx == -1:
return "", content
end_idx = _find_marker_index(content, end_marker, start_idx + len(start_marker))
if end_idx == -1 or end_idx <= start_idx:
return "", content
extracted = content[start_idx + len(start_marker):end_idx].strip()
remaining = content[:start_idx] + content[end_idx + len(end_marker):]
return extracted, remaining
def _extract_quiz_sections(content):
"""Ekstrak seluruh blok key quiz dalam URUT DOKUMEN.
Mendukung BEBERAPA blok per key (mis. dua blok QUIZ_MCQ). Return
(sections, remaining_content) dengan `sections` = list of (kind, raw_text)
terurut posisi marker start — supaya id soal (q-0, q-1, ...) deterministik
dan identik backend↔frontend.
"""
sections = []
while True:
best_idx = None
best_spec = None
for parse_kind, start_marker, end_marker in QUIZ_SECTION_SPECS:
idx = _find_marker_index(content, start_marker)
if idx != -1 and (best_idx is None or idx < best_idx):
best_idx = idx
best_spec = (parse_kind, start_marker, end_marker)
if best_spec is None:
break
parse_kind, start_marker, end_marker = best_spec
raw, content = _extract_quiz_section(content, start_marker, end_marker)
if not raw:
# Blok kosong → marker sudah terangkat, cukup lanjut. Blok tidak
# valid (marker end hilang) → konten TIDAK berubah; buang marker
# start supaya loop selalu maju (tidak memilih marker yang sama
# lagi tanpa henti).
idx = _find_marker_index(content, start_marker)
if idx != -1:
content = content[:idx] + content[idx + len(start_marker):]
continue
sections.append((parse_kind, raw))
return sections, content
def _extract_section(content, start_marker, end_marker):
"""Extract text between markers and return (extracted, remaining_content)."""
if start_marker not in content or end_marker not in content:
return "", content
start_idx = content.find(start_marker)
end_idx = content.find(end_marker)
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
return "", content
extracted = content[start_idx + len(start_marker):end_idx].strip()
remaining = content[:start_idx] + content[end_idx + len(end_marker):]
return extracted, remaining
def _render_markdown_string(content):
"""Parse markdown text (no file I/O) -> dict struktur sama seperti sebelumnya.
Dipakai render_markdown_content() (dari disk) DAN endpoint preview editor
(dari buffer draft belum tersimpan). Bisa raise ValueError bila quiz tidak
valid (lihat _parse_flashcards) — pemanggil wajib menangani.
"""
lesson_content = content
active_tabs = []
# Check for collective tags before extracting them
# Priority: INITIAL_CODE_ARDUINO → velxio mode (exclusive, ignores C/Python tabs)
if '---INITIAL_CODE_ARDUINO---' in lesson_content:
active_tabs.append('velxio')
else:
if '---INITIAL_CODE---' in lesson_content:
active_tabs.append('c')
if '---INITIAL_PYTHON---' in lesson_content:
active_tabs.append('python')
if '---INITIAL_CIRCUIT---' in lesson_content:
active_tabs.append('circuit')
if '---INITIAL_FLOWCHART---' in lesson_content:
active_tabs.append('flowchart')
# Semua key quiz (baru + legacy) mengaktifkan tab 'quiz' — dedupe agar
# tab tidak dobel bila beberapa key dipakai dalam satu file.
if any(marker in lesson_content for marker in QUIZ_TAB_MARKERS):
active_tabs.append('quiz')
# Velxio circuit-only: has VELXIO_CIRCUIT but no INITIAL_CODE_ARDUINO
if '---VELXIO_CIRCUIT---' in lesson_content and 'velxio' not in active_tabs:
active_tabs.append('velxio')
# Default to 'c' if nothing specified (for backwards compatibility)
if not active_tabs and '---INITIAL_CODE---' not in lesson_content and '---INITIAL_PYTHON---' not in lesson_content and '---INITIAL_CIRCUIT---' not in lesson_content and '---INITIAL_FLOWCHART---' not in lesson_content and not any(marker in lesson_content for marker in QUIZ_TAB_MARKERS):
# If it's a completely plain old file, assume it has a code editor available
if '---EXERCISE---' in lesson_content:
active_tabs.append('c')
# Extract special sections (order matters — each extraction removes the section)
expected_output, lesson_content = _extract_section(
lesson_content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---')
expected_output_python, lesson_content = _extract_section(
lesson_content, '---EXPECTED_OUTPUT_PYTHON---', '---END_EXPECTED_OUTPUT_PYTHON---')
expected_circuit_output, lesson_content = _extract_section(
lesson_content, '---EXPECTED_CIRCUIT_OUTPUT---', '---END_EXPECTED_CIRCUIT_OUTPUT---')
key_text, lesson_content = _extract_section(
lesson_content, '---KEY_TEXT---', '---END_KEY_TEXT---')
key_text_circuit, lesson_content = _extract_section(
lesson_content, '---KEY_TEXT_CIRCUIT---', '---END_KEY_TEXT_CIRCUIT---')
# Lesson info has a special fallback for old format
lesson_info = ""
if '---LESSON_INFO---' in lesson_content and '---END_LESSON_INFO---' in lesson_content:
lesson_info, lesson_content = _extract_section(
lesson_content, '---LESSON_INFO---', '---END_LESSON_INFO---')
elif '---LESSON_INFO---' in lesson_content:
parts = lesson_content.split('---LESSON_INFO---', 1)
if len(parts) == 2:
lesson_info = parts[0].strip()
lesson_content = parts[1].strip()
solution_code, lesson_content = _extract_section(
lesson_content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---')
solution_circuit, lesson_content = _extract_section(
lesson_content, '---SOLUTION_CIRCUIT---', '---END_SOLUTION_CIRCUIT---')
solution_python, lesson_content = _extract_section(
lesson_content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---')
# Initial codes (C, Python, Circuit, Quiz)
initial_code_c, lesson_content = _extract_section(
lesson_content, '---INITIAL_CODE---', '---END_INITIAL_CODE---')
initial_python, lesson_content = _extract_section(
lesson_content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---')
initial_circuit, lesson_content = _extract_section(
lesson_content, '---INITIAL_CIRCUIT---', '---END_INITIAL_CIRCUIT---')
initial_flowchart_str, lesson_content = _extract_section(
lesson_content, '---INITIAL_FLOWCHART---', '---END_INITIAL_FLOWCHART---')
initial_flowchart = None
if initial_flowchart_str:
import json
try:
initial_flowchart = json.loads(initial_flowchart_str)
except:
initial_flowchart = {}
initial_quiz, lesson_content = _extract_section(
lesson_content, '---INITIAL_QUIZ---', '---END_INITIAL_QUIZ---')
# Blok kuis (key baru + legacy) diekstrak URUT DOKUMEN lalu digabung
# menjadi satu daftar soal datar — sesi/attempt kuis tetap satu untuk
# semua jenis (taksonomi key tidak memecah sesi).
quiz_sections, lesson_content = _extract_quiz_sections(lesson_content)
quiz_data = []
for parse_kind, raw_text in quiz_sections:
quiz_data.extend(
_parse_flashcards(raw_text, kind=parse_kind, id_offset=len(quiz_data))
)
# Arduino/Velxio sections
initial_code_arduino, lesson_content = _extract_section(
lesson_content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---')
velxio_circuit, lesson_content = _extract_section(
lesson_content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---')
expected_serial_output, lesson_content = _extract_section(
lesson_content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---')
expected_wiring, lesson_content = _extract_section(
lesson_content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---')
expected_flowchart, lesson_content = _extract_section(
lesson_content, '---EXPECTED_FLOWCHART---', '---END_EXPECTED_FLOWCHART---')
evaluation_config, lesson_content = _extract_section(
lesson_content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---')
# Extract Slides
slides_raw, _ = _extract_section(lesson_content, '---slide-start---', '---slide-end---')
slides_html = []
if slides_raw:
# Replace the entire slide block with a mount point in the lesson_content
# We need to find the exact indices to replace it surgically
start_marker = '---slide-start---'
end_marker = '---slide-end---'
s_idx = lesson_content.find(start_marker)
e_idx = lesson_content.find(end_marker)
if s_idx != -1 and e_idx != -1 and e_idx > s_idx:
lesson_content = (
lesson_content[:s_idx] +
'<div id="slide-mount-point"></div>' +
lesson_content[e_idx + len(end_marker):]
)
# Parse slides
slide_parts = re.split(r'^\s*---\s*$', slides_raw, flags=re.MULTILINE)
for s in slide_parts:
if s.strip():
# Process embeds in slides too
s = _process_circuit_embeds(s)
s = _process_flowchart_embeds(s)
s = _process_embed_embeds(s)
slides_html.append(md.markdown(s.strip(), extensions=MD_EXTENSIONS))
# Just use whichever initial code matched as the generic 'initial_code' for simplicity
# if only one type exists, but return all as dictionary values.
# Typically frontend uses 'initial_code' for legacy.
initial_code = initial_code_c or initial_python or initial_circuit or initial_quiz
# Split lesson vs exercise
parts = lesson_content.split('---EXERCISE---')
lesson_content = parts[0] if parts else lesson_content
exercise_content = parts[1] if len(parts) > 1 else ""
# Convert ```circuit and ```flowchart fences to embed divs before markdown rendering
lesson_content = _process_circuit_embeds(lesson_content)
lesson_content = _process_flowchart_embeds(lesson_content)
lesson_content = _process_embed_embeds(lesson_content)
if exercise_content:
exercise_content = _process_circuit_embeds(exercise_content)
exercise_content = _process_flowchart_embeds(exercise_content)
exercise_content = _process_embed_embeds(exercise_content)
if lesson_info:
lesson_info = _process_circuit_embeds(lesson_info)
lesson_info = _process_flowchart_embeds(lesson_info)
lesson_info = _process_embed_embeds(lesson_info)
lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS)
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""
parsed_data = {
'lesson_html': lesson_html,
'exercise_html': exercise_html,
'expected_output': expected_output,
'expected_output_python': expected_output_python,
'expected_circuit_output': expected_circuit_output,
'expected_flowchart': expected_flowchart,
'lesson_info': lesson_info_html,
'initial_code': initial_code,
'solution_code': solution_code,
'solution_circuit': solution_circuit,
'solution_python': solution_python,
'key_text': key_text,
'key_text_circuit': key_text_circuit,
'initial_code_c': initial_code_c,
'initial_python': initial_python,
'initial_circuit': initial_circuit,
'initial_flowchart': initial_flowchart_str or initial_flowchart,
'initial_quiz': initial_quiz,
'initial_code_arduino': initial_code_arduino,
'velxio_circuit': velxio_circuit,
'expected_serial_output': expected_serial_output,
'expected_wiring': expected_wiring,
'evaluation_config': evaluation_config,
'quiz_data': quiz_data,
'active_tabs': active_tabs,
'slides': slides_html
}
return parsed_data
def render_markdown_content(file_path):
"""Parse a lesson markdown file and return structured HTML parts as a dictionary."""
try:
current_mtime = os.path.getmtime(file_path)
except OSError:
current_mtime = 0.0
with _markdown_lock:
cached = _markdown_cache.get(file_path)
if cached and cached['mtime'] == current_mtime:
return cached['data']
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
parsed_data = _render_markdown_string(content)
with _markdown_lock:
if len(_markdown_cache) >= 128:
_markdown_cache.clear()
_markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime}
return parsed_data
def render_home_content():
"""Render the home.md intro section (before Available_Lessons) as HTML.
Not cached separately — relies on _read_home_md() mtime-based cache,
which is already fast (1 syscall per request). This avoids the lru_cache
multi-worker stale data problem.
"""
home_content = _read_home_md()
if not home_content:
return ""
# Use robust regex to split Available_Lessons
parts = re.split(r'-{3,}Available_Lessons-{3,}', home_content)
main_content = parts[0] if parts else home_content
return md.markdown(main_content, extensions=['fenced_code', 'tables', 'mdx_math'])
# Tab-tipe yang dianggap "latihan" (memerlukan penyelesaian kode/alur).
_EXERCISE_TABS = {"c", "python", "circuit", "flowchart", "velxio"}
def get_evaluation_signature(content: str) -> dict:
"""Ringkasan jenis evaluasi dari isi markdown mentah (bukan dari disk).
Dipakai endpoint publish untuk mendeteksi perubahan jenis evaluasi SEBELUM
vs SESUDAH guru menyimpan materi, sehingga progress siswa bisa langsung
direkonsiliasi tanpa menunggu restart server.
Return dict:
- has_exercise: ada tab latihan (C/Python/Circuit/Flowchart/Velxio)
- has_quiz: ada blok kuis (semua key quiz, termasuk legacy)
- quiz_kinds: jenis kuis yang muncul ('mcq'/'weighted'/'flashcard')
- quiz_total: jumlah soal, atau 100 bila memuat soal weighted
Raise ValueError bila blok kuis tidak valid (sama seperti parser materi).
"""
parsed = _render_markdown_string(content)
tabs = set(parsed.get("active_tabs", []))
quiz_data = parsed.get("quiz_data") or []
is_weighted = any(q.get("quiz_type") == "weighted" for q in quiz_data)
return {
"has_exercise": bool(tabs & _EXERCISE_TABS),
"has_quiz": "quiz" in tabs,
"quiz_kinds": sorted(
{q.get("quiz_type") for q in quiz_data if q.get("quiz_type")}
),
"quiz_total": 100 if is_weighted else len(quiz_data),
}
def get_lesson_components(lesson_name: str) -> tuple[bool, bool]:
"""Tentukan apakah lesson memiliki komponen latihan dan/atau kuis.
Berdasarkan active_tabs hasil render markdown lesson. Lesson reading-only
(tanpa exercise maupun quiz) -> (False, False) -> auto-done composite=100.
"""
file_path = find_lesson_file(f"{lesson_name}.md")
if not file_path:
return (False, False)
parsed = render_markdown_content(file_path)
tabs = set(parsed.get("active_tabs", []))
has_exercise = bool(tabs & _EXERCISE_TABS)
has_quiz = "quiz" in tabs
return (has_exercise, has_quiz)
def get_lesson_quiz_total(lesson_name: str) -> int | None:
"""Jumlah soal kuis untuk lesson (atau 100 untuk kuis berbobot).
Kuis yang memuat soal weighted memakai format skor persen "P/100" — jadi
totalnya 100, bukan jumlah soal. Tanpa penyesuaian ini format impor/
ekspor CSV progress siswa akan tidak cocok dengan skor yang tersimpan.
"""
file_path = find_lesson_file(f"{lesson_name}.md")
if not file_path:
return None
try:
parsed = render_markdown_content(file_path)
quiz_data = parsed.get("quiz_data") or []
if not quiz_data:
return None
if any(q.get("quiz_type") == "weighted" for q in quiz_data):
return 100
return len(quiz_data)
except Exception:
return None
def get_lesson_expected_format(lesson_name: str) -> str:
"""Tentukan panduan expected format kolom lesson berdasarkan komponennya.
- Exercise only: 'completed'
- Quiz only: '<earned>/N' jika total soal diketahui, atau '<earned>/<total>'
- Exercise + Quiz: 'done:<1|0>:<earned>/N' (atau '<total>')
- Reading only: 'completed'
"""
has_exercise, has_quiz = get_lesson_components(lesson_name)
quiz_total = get_lesson_quiz_total(lesson_name) if has_quiz else None
if has_exercise and has_quiz:
q_fmt = f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return f"done:<1|0>:{q_fmt}"
if has_quiz:
return f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return "completed"