diff --git a/elemes.sh b/elemes.sh index b50916a..4796532 100755 --- a/elemes.sh +++ b/elemes.sh @@ -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 diff --git a/frontend/src/lib/types/lesson.ts b/frontend/src/lib/types/lesson.ts index 7c03dda..ae39155 100644 --- a/frontend/src/lib/types/lesson.ts +++ b/frontend/src/lib/types/lesson.ts @@ -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; diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 8cc4f61..45fff2b 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -38,6 +38,16 @@ let circuitLoading = $state(false); let circuitSuccess = $state(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,7 +147,23 @@ 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; const simApi = circuitEditor.getApi(); @@ -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 { diff --git a/generate_tokens.py b/generate_tokens.py index 202fd9d..5728ff5 100644 --- a/generate_tokens.py +++ b/generate_tokens.py @@ -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() diff --git a/routes/lessons.py b/routes/lessons.py index 5193294..6e73069 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -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, diff --git a/services/lesson_service.py b/services/lesson_service.py index 2210f2e..8ea9353 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -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,