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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
case "$1" in
stop | run | runbuild)
echo "Stop Container..."
podman-compose --env-file ../.env down
;;&
runbuild)
echo "Build and Run Container..."
podman-compose --env-file ../.env up --build --force-recreate -d
;;&
run)
echo "Run Container..."
podman-compose --env-file ../.env up -d
;;&
stop)
podman-compose --env-file ../.env down
generatetoken)
echo "Generating tokens_siswa.csv from content..."
python3 "$SCRIPT_DIR/generate_tokens.py"
;;&
*)
echo "elemes.sh ( run | runbuild | stop | generatetoken )"
;;
esac

View File

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

View File

@ -38,6 +38,16 @@
let circuitLoading = $state(false);
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)
let compiling = $derived(codeLoading || circuitLoading);
@ -90,6 +100,8 @@
circuitOutput = '';
circuitError = '';
circuitSuccess = null;
codePassed = false;
circuitPassed = false;
showSolution = false;
if (lesson.lesson_info) activeTab = 'info';
else if (lesson.exercise_content) activeTab = 'exercise';
@ -135,6 +147,22 @@
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() {
if (!data || !circuitEditor) return;
@ -153,10 +181,15 @@
activeTab = 'output';
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;
try {
if (data.expected_output) {
expectedState = JSON.parse(data.expected_output);
if (circuitExpected) {
expectedState = JSON.parse(circuitExpected);
}
} catch (e) {
circuitError = "Format EXPECTED_OUTPUT tidak valid (Harus JSON).";
@ -199,7 +232,11 @@
// GWT getInfo() returns Java array yang sulit di-parse dari JS.
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) {
allPassed = false;
messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`);
@ -209,17 +246,20 @@
circuitSuccess = allPassed;
if (allPassed) {
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);
circuitPassed = true;
if (isHybrid) {
circuitOutput += '\n✅ Rangkaian benar!';
if (!codePassed) {
circuitOutput += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
}
}
if (checkAllPassed()) {
await completeLesson();
setTimeout(() => {
showCelebration = false;
activeTab = 'circuit';
}, 3000);
}
setTimeout(() => {
showCelebration = false;
activeTab = 'circuit';
}, 3000);
}
} catch (err: any) {
circuitError = `Evaluasi gagal: ${err.message}`;
@ -253,22 +293,23 @@
const outputMatch = res.output.trim() === data.expected_output.trim();
const keyTextMatch = checkKeyText(code, data.key_text ?? '');
if (outputMatch && keyTextMatch) {
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);
codePassed = true;
if (isHybrid && !circuitPassed) {
codeOutput += '\n✅ Kode benar!';
codeOutput += '\n⏳ Selesaikan juga tantangan rangkaian untuk menyelesaikan pelajaran ini.';
}
// Auto-show solution after celebration
if (data.solution_code) {
showSolution = true;
editor?.setCode(data.solution_code);
if (checkAllPassed()) {
await completeLesson();
// Auto-show solution after celebration
if (data.solution_code) {
showSolution = true;
editor?.setCode(data.solution_code);
}
setTimeout(() => {
showCelebration = false;
activeTab = 'editor';
}, 3000);
}
setTimeout(() => {
showCelebration = false;
activeTab = 'editor';
}, 3000);
}
}
} else {

View File

@ -1,20 +1,25 @@
#!/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 glob
import uuid
import os
import re
import stat
# Configuration
CONTENT_DIR = '../content'
TOKENS_FILE = '../tokens_siswa.csv'
def get_lesson_names():
"""Get all lesson names from the content directory (excluding home.md)"""
# First, try to get the lesson order from home.md
"""Get all lesson names from the content directory (excluding home.md)."""
home_file_path = os.path.join(CONTENT_DIR, "home.md")
lesson_names = []
@ -22,68 +27,89 @@ def get_lesson_names():
with open(home_file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Split content to get only the lesson list part
parts = content.split('---Available_Lessons---')
if len(parts) > 1:
lesson_list_content = parts[1]
# Find lesson links in the lesson list content
import re
# Look for markdown links that point to lessons
lesson_links = re.findall(r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content)
lesson_links = re.findall(
r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content
)
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', ''))
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"))
for file_path in lesson_files:
filename = os.path.basename(file_path)
# Skip home.md as it's not a lesson
if filename == "home.md":
continue
lesson_names.append(filename.replace('.md', ''))
# Sort alphabetically to have consistent order
lesson_names.sort()
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
headers = ['token', 'nama_siswa'] + lesson_names
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
def _set_permissions(path):
"""Set rw-rw-rw- so the container can update the file."""
try:
import stat
import os
current_permissions = os.stat(TOKENS_FILE).st_mode
os.chmod(TOKENS_FILE, current_permissions | stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH)
print(f"Set permissions for {TOKENS_FILE} to allow container access")
cur = os.stat(path).st_mode
os.chmod(path, cur | stat.S_IRUSR | stat.S_IWUSR
| stat.S_IRGRP | stat.S_IWGRP
| stat.S_IROTH | stat.S_IWOTH)
print(f"Set permissions for {path} to allow container access")
except Exception as 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.")
print("An example row has been added with token 'dummy_token_12345' for reference.")
print("To add new students, add new rows with format: token;nama_siswa;lesson1_status;lesson2_status;...")
def generate_tokens_csv():
"""Generate or update the tokens CSV file."""
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__':
generate_tokens_csv()

View File

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

View File

@ -278,9 +278,15 @@ def render_markdown_content(file_path):
expected_output, lesson_content = _extract_section(
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(
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:
@ -336,11 +342,13 @@ def render_markdown_content(file_path):
'lesson_html': lesson_html,
'exercise_html': exercise_html,
'expected_output': expected_output,
'expected_circuit_output': expected_circuit_output,
'lesson_info': lesson_info_html,
'initial_code': initial_code,
'solution_code': solution_code,
'solution_circuit': solution_circuit,
'key_text': key_text,
'key_text_circuit': key_text_circuit,
'initial_code_c': initial_code_c,
'initial_python': initial_python,
'initial_circuit': initial_circuit,