feat: Enhance lesson functionality with circuit output and key text support, update token generation script for better CSV handling

master v2.1
a2nr 2026-03-31 14:31:15 +07:00
parent d29c2f2e3e
commit 1e6c6a884c
6 changed files with 169 additions and 76 deletions

View File

@ -1,13 +1,25 @@
#!/bin/bash #!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
case "$1" in case "$1" in
stop | run | runbuild)
echo "Stop Container..."
podman-compose --env-file ../.env down
;;&
runbuild) runbuild)
echo "Build and Run Container..."
podman-compose --env-file ../.env up --build --force-recreate -d podman-compose --env-file ../.env up --build --force-recreate -d
;;& ;;&
run) run)
echo "Run Container..."
podman-compose --env-file ../.env up -d podman-compose --env-file ../.env up -d
;;& ;;&
stop) generatetoken)
podman-compose --env-file ../.env down echo "Generating tokens_siswa.csv from content..."
python3 "$SCRIPT_DIR/generate_tokens.py"
;;&
*)
echo "elemes.sh ( run | runbuild | stop | generatetoken )"
;; ;;
esac esac

View File

@ -9,6 +9,8 @@ export interface LessonContent {
lesson_content: string; lesson_content: string;
exercise_content: string; exercise_content: string;
expected_output: string; expected_output: string;
expected_circuit_output: string;
key_text_circuit: string;
lesson_info: string; lesson_info: string;
initial_code: string; initial_code: string;
initial_code_c: string; initial_code_c: string;

View File

@ -38,6 +38,16 @@
let circuitLoading = $state(false); let circuitLoading = $state(false);
let circuitSuccess = $state<boolean | null>(null); let circuitSuccess = $state<boolean | null>(null);
// AND-logic: track whether each exercise type has passed (persists across runs)
let codePassed = $state(false);
let circuitPassed = $state(false);
// Derived: is this a hybrid lesson (has both code and circuit)?
let isHybrid = $derived(
(data?.active_tabs?.includes('c') || data?.active_tabs?.includes('python')) &&
data?.active_tabs?.includes('circuit')
);
// Derived: any loading state (for disabling Run button) // Derived: any loading state (for disabling Run button)
let compiling = $derived(codeLoading || circuitLoading); let compiling = $derived(codeLoading || circuitLoading);
@ -90,6 +100,8 @@
circuitOutput = ''; circuitOutput = '';
circuitError = ''; circuitError = '';
circuitSuccess = null; circuitSuccess = null;
codePassed = false;
circuitPassed = false;
showSolution = false; showSolution = false;
if (lesson.lesson_info) activeTab = 'info'; if (lesson.lesson_info) activeTab = 'info';
else if (lesson.exercise_content) activeTab = 'exercise'; else if (lesson.exercise_content) activeTab = 'exercise';
@ -135,6 +147,22 @@
return keys.every(key => code.includes(key)); return keys.every(key => code.includes(key));
} }
/** Mark lesson as complete: track progress + celebration. Called when ALL exercises pass. */
async function completeLesson() {
showCelebration = true;
if (auth.isLoggedIn) {
const lessonName = slug.replace('.md', '');
await trackProgress(auth.token, lessonName);
lessonCompleted = true;
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
}
}
/** For hybrid lessons, check if all exercise types have passed (AND logic). */
function checkAllPassed(): boolean {
if (!isHybrid) return true; // not hybrid, each evaluator handles itself
return codePassed && circuitPassed;
}
async function evaluateCircuit() { async function evaluateCircuit() {
if (!data || !circuitEditor) return; if (!data || !circuitEditor) return;
@ -153,10 +181,15 @@
activeTab = 'output'; activeTab = 'output';
try { try {
// For hybrid lessons, use expected_circuit_output; otherwise fallback to expected_output
const circuitExpected = (isHybrid && data.expected_circuit_output)
? data.expected_circuit_output
: data.expected_output;
let expectedState: any = null; let expectedState: any = null;
try { try {
if (data.expected_output) { if (circuitExpected) {
expectedState = JSON.parse(data.expected_output); expectedState = JSON.parse(circuitExpected);
} }
} catch (e) { } catch (e) {
circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON)."; circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON).";
@ -199,7 +232,11 @@
// GWT getInfo() returns Java array yang sulit di-parse dari JS. // GWT getInfo() returns Java array yang sulit di-parse dari JS.
const circuitText = circuitEditor.getCircuitText(); const circuitText = circuitEditor.getCircuitText();
const keyTextMatch = checkKeyText(circuitText, data.key_text ?? ''); // For hybrid lessons, use key_text_circuit; otherwise fallback to key_text
const circuitKeyText = (isHybrid && data.key_text_circuit)
? data.key_text_circuit
: data.key_text;
const keyTextMatch = checkKeyText(circuitText, circuitKeyText ?? '');
if (!keyTextMatch) { if (!keyTextMatch) {
allPassed = false; allPassed = false;
messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`); messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`);
@ -209,17 +246,20 @@
circuitSuccess = allPassed; circuitSuccess = allPassed;
if (allPassed) { if (allPassed) {
showCelebration = true; circuitPassed = true;
if (auth.isLoggedIn) { if (isHybrid) {
const lessonName = slug.replace('.md', ''); circuitOutput += '\n✅ Rangkaian benar!';
await trackProgress(auth.token, lessonName); if (!codePassed) {
lessonCompleted = true; circuitOutput += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx); }
}
if (checkAllPassed()) {
await completeLesson();
setTimeout(() => {
showCelebration = false;
activeTab = 'circuit';
}, 3000);
} }
setTimeout(() => {
showCelebration = false;
activeTab = 'circuit';
}, 3000);
} }
} catch (err: any) { } catch (err: any) {
circuitError = `Evaluasi gagal: ${err.message}`; circuitError = `Evaluasi gagal: ${err.message}`;
@ -253,22 +293,23 @@
const outputMatch = res.output.trim() === data.expected_output.trim(); const outputMatch = res.output.trim() === data.expected_output.trim();
const keyTextMatch = checkKeyText(code, data.key_text ?? ''); const keyTextMatch = checkKeyText(code, data.key_text ?? '');
if (outputMatch && keyTextMatch) { if (outputMatch && keyTextMatch) {
showCelebration = true; codePassed = true;
if (auth.isLoggedIn) { if (isHybrid && !circuitPassed) {
const lessonName = slug.replace('.md', ''); codeOutput += '\n✅ Kode benar!';
await trackProgress(auth.token, lessonName); codeOutput += '\n⏳ Selesaikan juga tantangan rangkaian untuk menyelesaikan pelajaran ini.';
lessonCompleted = true;
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
} }
// Auto-show solution after celebration if (checkAllPassed()) {
if (data.solution_code) { await completeLesson();
showSolution = true; // Auto-show solution after celebration
editor?.setCode(data.solution_code); if (data.solution_code) {
showSolution = true;
editor?.setCode(data.solution_code);
}
setTimeout(() => {
showCelebration = false;
activeTab = 'editor';
}, 3000);
} }
setTimeout(() => {
showCelebration = false;
activeTab = 'editor';
}, 3000);
} }
} }
} else { } else {

View File

@ -1,20 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Script to generate the tokens CSV file based on lessons in the content directory Script to generate/update the tokens CSV file based on lessons in the content directory.
If the CSV already exists, new lesson columns are added (with 'not_started')
and existing student data is preserved. If it doesn't exist, a fresh file
is created with a dummy example row.
""" """
import os
import csv import csv
import glob import glob
import uuid import os
import re
import stat
# Configuration # Configuration
CONTENT_DIR = '../content' CONTENT_DIR = '../content'
TOKENS_FILE = '../tokens_siswa.csv' TOKENS_FILE = '../tokens_siswa.csv'
def get_lesson_names(): def get_lesson_names():
"""Get all lesson names from the content directory (excluding home.md)""" """Get all lesson names from the content directory (excluding home.md)."""
# First, try to get the lesson order from home.md
home_file_path = os.path.join(CONTENT_DIR, "home.md") home_file_path = os.path.join(CONTENT_DIR, "home.md")
lesson_names = [] lesson_names = []
@ -22,68 +27,89 @@ def get_lesson_names():
with open(home_file_path, 'r', encoding='utf-8') as f: with open(home_file_path, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
# Split content to get only the lesson list part
parts = content.split('---Available_Lessons---') parts = content.split('---Available_Lessons---')
if len(parts) > 1: if len(parts) > 1:
lesson_list_content = parts[1] lesson_list_content = parts[1]
lesson_links = re.findall(
# Find lesson links in the lesson list content r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content
import re )
# Look for markdown links that point to lessons
lesson_links = re.findall(r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content)
if lesson_links: if lesson_links:
# Create ordered list based on home.md for _link_text, filename in lesson_links:
for link_text, filename in lesson_links:
lesson_names.append(filename.replace('.md', '')) lesson_names.append(filename.replace('.md', ''))
return lesson_names return lesson_names
# If no specific order is defined in home.md, fall back to alphabetical order # Fallback: alphabetical order
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md")) lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
for file_path in lesson_files: for file_path in lesson_files:
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
# Skip home.md as it's not a lesson
if filename == "home.md": if filename == "home.md":
continue continue
lesson_names.append(filename.replace('.md', '')) lesson_names.append(filename.replace('.md', ''))
# Sort alphabetically to have consistent order
lesson_names.sort() lesson_names.sort()
return lesson_names return lesson_names
def generate_tokens_csv():
"""Generate the tokens CSV file with headers and lesson columns"""
lesson_names = get_lesson_names()
# Create the file with headers def _set_permissions(path):
headers = ['token', 'nama_siswa'] + lesson_names """Set rw-rw-rw- so the container can update the file."""
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile, delimiter=';')
writer.writerow(headers)
# Add a dummy example row for reference
dummy_row = ['dummy_token_12345', 'Example Student'] + ['not_started'] * len(lesson_names)
writer.writerow(dummy_row)
# Set file permissions to allow read/write access for all users
# This ensures the container can update the file when progress is tracked
try: try:
import stat cur = os.stat(path).st_mode
import os os.chmod(path, cur | stat.S_IRUSR | stat.S_IWUSR
current_permissions = os.stat(TOKENS_FILE).st_mode | stat.S_IRGRP | stat.S_IWGRP
os.chmod(TOKENS_FILE, current_permissions | stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH) | stat.S_IROTH | stat.S_IWOTH)
print(f"Set permissions for {TOKENS_FILE} to allow container access") print(f"Set permissions for {path} to allow container access")
except Exception as e: except Exception as e:
print(f"Warning: Could not set file permissions: {e}") print(f"Warning: Could not set file permissions: {e}")
print(f"Created tokens file: {TOKENS_FILE} with headers: {headers}")
print("Teachers can now add student tokens and names directly to this file.") def generate_tokens_csv():
print("An example row has been added with token 'dummy_token_12345' for reference.") """Generate or update the tokens CSV file."""
print("To add new students, add new rows with format: token;nama_siswa;lesson1_status;lesson2_status;...") lesson_names = get_lesson_names()
new_headers = ['token', 'nama_siswa'] + lesson_names
if os.path.exists(TOKENS_FILE):
# ── Merge mode: preserve existing data, add new columns ──
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as f:
reader = csv.DictReader(f, delimiter=';')
old_headers = list(reader.fieldnames) if reader.fieldnames else []
rows = list(reader)
added_cols = [h for h in new_headers if h not in old_headers]
removed_cols = [h for h in old_headers if h not in new_headers
and h not in ('token', 'nama_siswa')]
# Rebuild each row with the new header order
merged_rows = []
for row in rows:
new_row = {}
for h in new_headers:
new_row[h] = row.get(h, 'not_started')
merged_rows.append(new_row)
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=new_headers, delimiter=';')
writer.writeheader()
writer.writerows(merged_rows)
if added_cols:
print(f"Kolom baru ditambahkan: {added_cols}")
if removed_cols:
print(f"Kolom lama dihapus: {removed_cols}")
print(f"Updated {TOKENS_FILE}{len(merged_rows)} siswa dipertahankan.")
else:
# ── Fresh mode: create new file ──
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f, delimiter=';')
writer.writerow(new_headers)
dummy_row = ['dummy_token_12345', 'Example Student'] + \
['not_started'] * len(lesson_names)
writer.writerow(dummy_row)
print(f"Created tokens file: {TOKENS_FILE}")
print("Tambahkan siswa dengan format: token;nama_siswa;status;...")
_set_permissions(TOKENS_FILE)
print(f"Headers: {new_headers}")
if __name__ == '__main__': if __name__ == '__main__':
generate_tokens_csv() generate_tokens_csv()

View File

@ -48,6 +48,8 @@ def api_lesson(filename):
lesson_html = parsed_data['lesson_html'] lesson_html = parsed_data['lesson_html']
exercise_html = parsed_data['exercise_html'] exercise_html = parsed_data['exercise_html']
expected_output = parsed_data['expected_output'] expected_output = parsed_data['expected_output']
expected_circuit_output = parsed_data.get('expected_circuit_output', '')
key_text_circuit = parsed_data.get('key_text_circuit', '')
lesson_info = parsed_data['lesson_info'] lesson_info = parsed_data['lesson_info']
initial_code = parsed_data['initial_code'] initial_code = parsed_data['initial_code']
solution_code = parsed_data['solution_code'] solution_code = parsed_data['solution_code']
@ -95,6 +97,7 @@ def api_lesson(filename):
'lesson_content': lesson_html, 'lesson_content': lesson_html,
'exercise_content': exercise_html, 'exercise_content': exercise_html,
'expected_output': expected_output, 'expected_output': expected_output,
'expected_circuit_output': expected_circuit_output,
'lesson_info': lesson_info, 'lesson_info': lesson_info,
'initial_code': initial_code, 'initial_code': initial_code,
'initial_circuit': initial_circuit, 'initial_circuit': initial_circuit,
@ -104,6 +107,7 @@ def api_lesson(filename):
'solution_code': solution_code, 'solution_code': solution_code,
'solution_circuit': solution_circuit, 'solution_circuit': solution_circuit,
'key_text': key_text, 'key_text': key_text,
'key_text_circuit': key_text_circuit,
'active_tabs': active_tabs, 'active_tabs': active_tabs,
'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(), 'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(),
'lesson_completed': lesson_completed, 'lesson_completed': lesson_completed,

View File

@ -278,9 +278,15 @@ def render_markdown_content(file_path):
expected_output, lesson_content = _extract_section( expected_output, lesson_content = _extract_section(
lesson_content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---') lesson_content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---')
expected_circuit_output, lesson_content = _extract_section(
lesson_content, '---EXPECTED_CIRCUIT_OUTPUT---', '---END_EXPECTED_CIRCUIT_OUTPUT---')
key_text, lesson_content = _extract_section( key_text, lesson_content = _extract_section(
lesson_content, '---KEY_TEXT---', '---END_KEY_TEXT---') 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 has a special fallback for old format
lesson_info = "" lesson_info = ""
if '---LESSON_INFO---' in lesson_content and '---END_LESSON_INFO---' in lesson_content: if '---LESSON_INFO---' in lesson_content and '---END_LESSON_INFO---' in lesson_content:
@ -336,11 +342,13 @@ def render_markdown_content(file_path):
'lesson_html': lesson_html, 'lesson_html': lesson_html,
'exercise_html': exercise_html, 'exercise_html': exercise_html,
'expected_output': expected_output, 'expected_output': expected_output,
'expected_circuit_output': expected_circuit_output,
'lesson_info': lesson_info_html, 'lesson_info': lesson_info_html,
'initial_code': initial_code, 'initial_code': initial_code,
'solution_code': solution_code, 'solution_code': solution_code,
'solution_circuit': solution_circuit, 'solution_circuit': solution_circuit,
'key_text': key_text, 'key_text': key_text,
'key_text_circuit': key_text_circuit,
'initial_code_c': initial_code_c, 'initial_code_c': initial_code_c,
'initial_python': initial_python, 'initial_python': initial_python,
'initial_circuit': initial_circuit, 'initial_circuit': initial_circuit,