From 7d590e0f95b09de680e848fff6a4c7cd14eecb88 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 6 Sep 2026 16:17:35 +0700 Subject: [PATCH] fix(content-editor): sync and auto-save velxio arduino draft on publish - Auto-save dirty draft before publishing in ContentEditorManager so in-memory Velxio circuit/code changes are never lost when clicking Publish directly - Add resolveRestoredContent and baseline tracking to velxio-manager so lesson view immediately reflects updated server code instead of stale localStorage - Invalidate in-memory lesson and markdown caches on draft publish and file operations - Update teacher content page publish button disabled condition to check saving state --- {services => cli}/tests/test_cli_tui.py | 0 .../src/lib/services/contentEditor.svelte.ts | 17 +++- .../src/lib/services/contentEditor.test.ts | 32 +++++++ .../src/lib/services/velxio-manager.test.ts | 80 +++++++++++++++- frontend/src/lib/services/velxio-manager.ts | 96 +++++++++++++------ .../src/routes/lesson/[slug]/lesson.svelte.ts | 23 +++-- .../src/routes/teacher/content/+page.svelte | 2 +- routes/content_editor.py | 7 +- services/lesson_service.py | 47 ++++++--- 9 files changed, 245 insertions(+), 59 deletions(-) rename {services => cli}/tests/test_cli_tui.py (100%) diff --git a/services/tests/test_cli_tui.py b/cli/tests/test_cli_tui.py similarity index 100% rename from services/tests/test_cli_tui.py rename to cli/tests/test_cli_tui.py diff --git a/frontend/src/lib/services/contentEditor.svelte.ts b/frontend/src/lib/services/contentEditor.svelte.ts index b55a5be..4b87a42 100644 --- a/frontend/src/lib/services/contentEditor.svelte.ts +++ b/frontend/src/lib/services/contentEditor.svelte.ts @@ -133,9 +133,24 @@ export class ContentEditorManager { } async handlePublish() { - if (!this.draftId || this.publishing) return; + if (!this.activePath || this.publishing) return; this.publishing = true; try { + if (this.dirty || !this.draftId) { + const saveRes = await saveDraft(this.activePath, this.body, this.baseMtime); + if (!saveRes.success) { + this.showMessage('error', saveRes.message || 'Gagal menyimpan draft sebelum publish'); + return; + } + this.draftId = saveRes.draft_id; + this.dirty = false; + } + + if (!this.draftId) { + this.showMessage('error', 'Draft ID tidak ditemukan untuk dipublikasikan'); + return; + } + const res = await publishDraft(this.draftId); if (res.success) { this.dirty = false; diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index c0dc8df..890de44 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -58,4 +58,36 @@ describe('renameEntry', () => { const body = JSON.parse(fetchMock.mock.calls[0][1]?.body ?? '{}'); expect(body.confirm_critical).toBe(true); }); +}); + +describe('saveDraft and publishDraft API', () => { + it('mengirim saveDraft dengan target_path dan body', async () => { + const { saveDraft } = await import('./contentEditor'); + const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true, draft_id: 'draft-1' }) }); + global.fetch = fetchMock; + + await saveDraft('arduino/led_blink_arduino.md', 'modified body', 12345); + + expect(fetchMock).toHaveBeenCalled(); + const call = fetchMock.mock.calls[0]; + expect(call[0]).toBe('/api/content/drafts'); + expect(JSON.parse(call[1].body)).toEqual({ + target_path: 'arduino/led_blink_arduino.md', + body: 'modified body', + base_mtime: 12345 + }); + }); + + it('mengirim publishDraft dengan draft_id ke endpoint publish', async () => { + const { publishDraft } = await import('./contentEditor'); + const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true, published_path: 'arduino/led_blink_arduino.md' }) }); + global.fetch = fetchMock; + + await publishDraft('draft-1'); + + expect(fetchMock).toHaveBeenCalled(); + const call = fetchMock.mock.calls[0]; + expect(call[0]).toBe('/api/content/drafts/draft-1/publish'); + expect(call[1].method).toBe('POST'); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/velxio-manager.test.ts b/frontend/src/lib/services/velxio-manager.test.ts index 148935a..f995bda 100644 --- a/frontend/src/lib/services/velxio-manager.test.ts +++ b/frontend/src/lib/services/velxio-manager.test.ts @@ -1,5 +1,16 @@ -import { describe, it, expect } from 'vitest'; -import { getVelxioState } from './velxio-manager'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { getVelxioState, resolveRestoredContent } from './velxio-manager'; + +class LocalStorageMock { + private store: Record = {}; + getItem(key: string) { return this.store[key] ?? null; } + setItem(key: string, val: string) { this.store[key] = String(val); } + removeItem(key: string) { delete this.store[key]; } + clear() { this.store = {}; } +} +const mockStorage = new LocalStorageMock(); +(global as any).localStorage = mockStorage; +(global as any).window = { localStorage: mockStorage }; describe('velxio-manager', () => { describe('getVelxioState', () => { @@ -137,4 +148,69 @@ describe('velxio-manager', () => { expect(parsedCircuit.wires).toEqual([]); }); }); + + describe('resolveRestoredContent', () => { + const KEY = 'test_arduino_code'; + const KEY_BASE = 'test_arduino_code_base'; + + beforeEach(() => { + localStorage.clear(); + }); + + it('returns initial content when localStorage is empty', () => { + const res = resolveRestoredContent(KEY, 'void setup() {}'); + expect(res.content).toBe('void setup() {}'); + expect(res.shouldRestore).toBe(false); + }); + + it('clears legacy draft (missing _base) and loads fresh server initial content', () => { + localStorage.setItem(KEY, 'legacy code from old auto-save'); + const res = resolveRestoredContent(KEY, 'void setup() { Serial.begin(9600); }'); + expect(res.content).toBe('void setup() { Serial.begin(9600); }'); + expect(res.shouldRestore).toBe(false); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it('clears draft when server initial content has changed (outdated baseline)', () => { + localStorage.setItem(KEY, 'student modified code'); + localStorage.setItem(KEY_BASE, 'old initial code v1'); + + const res = resolveRestoredContent(KEY, 'new initial code v2 published by teacher'); + expect(res.content).toBe('new initial code v2 published by teacher'); + expect(res.shouldRestore).toBe(false); + expect(localStorage.getItem(KEY)).toBeNull(); + expect(localStorage.getItem(KEY_BASE)).toBeNull(); + }); + + it('clears draft when saved code is identical to initial content (unmodified)', () => { + localStorage.setItem(KEY, 'void setup() {}'); + localStorage.setItem(KEY_BASE, 'void setup() {}'); + + const res = resolveRestoredContent(KEY, 'void setup() {}'); + expect(res.content).toBe('void setup() {}'); + expect(res.shouldRestore).toBe(false); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it('restores student draft when baseline matches and code was modified', () => { + localStorage.setItem(KEY, 'void setup() { pinMode(13, OUTPUT); }'); + localStorage.setItem(KEY_BASE, 'void setup() {}'); + + const res = resolveRestoredContent(KEY, 'void setup() {}'); + expect(res.content).toBe('void setup() { pinMode(13, OUTPUT); }'); + expect(res.shouldRestore).toBe(true); + expect(localStorage.getItem(KEY)).toBe('void setup() { pinMode(13, OUTPUT); }'); + }); + + it('always returns initial content and cleans up storage when isTeacher is true', () => { + localStorage.setItem(KEY, 'some draft code'); + localStorage.setItem(KEY_BASE, 'void setup() {}'); + + const res = resolveRestoredContent(KEY, 'void setup() {}', true); + expect(res.content).toBe('void setup() {}'); + expect(res.shouldRestore).toBe(false); + expect(localStorage.getItem(KEY)).toBeNull(); + expect(localStorage.getItem(KEY_BASE)).toBeNull(); + }); + }); }); diff --git a/frontend/src/lib/services/velxio-manager.ts b/frontend/src/lib/services/velxio-manager.ts index 1222ae2..96882e7 100644 --- a/frontend/src/lib/services/velxio-manager.ts +++ b/frontend/src/lib/services/velxio-manager.ts @@ -76,6 +76,43 @@ export function getVelxioState(velxioIframe: HTMLIFrameElement | null): { code: } } +export function resolveRestoredContent( + storageKey: string, + initialContent: string | undefined, + isTeacher = false +): { content: string; shouldRestore: boolean } { + const initial = initialContent ?? ''; + if (typeof window === 'undefined' || !window.localStorage) { + return { content: initial, shouldRestore: false }; + } + + if (isTeacher) { + localStorage.removeItem(storageKey); + localStorage.removeItem(storageKey + '_base'); + return { content: initial, shouldRestore: false }; + } + + const saved = localStorage.getItem(storageKey); + const savedBase = localStorage.getItem(storageKey + '_base'); + + // A draft is outdated if its baseline doesn't match the current initial content, + // or if it was saved by legacy code (no baseline recorded). + const isLegacy = savedBase === null; + const isOutdated = isLegacy || savedBase !== initial; + const isUnmodified = saved === initial || (savedBase !== null && saved === savedBase); + + if (saved && !isOutdated && !isUnmodified) { + return { content: saved, shouldRestore: true }; + } + + // Clean up stale or unmodified draft from storage + if (saved !== null || savedBase !== null) { + localStorage.removeItem(storageKey); + localStorage.removeItem(storageKey + '_base'); + } + return { content: initial, shouldRestore: false }; +} + export function initVelxioBridge( iframe: HTMLIFrameElement, data: LessonContent | null, @@ -83,7 +120,8 @@ export function initVelxioBridge( arduinoCodeKey: string, onReady: (bridge: VelxioBridge) => void, onSubmit: () => void, - onCountdown?: (msRemaining: number) => void + onCountdown?: (msRemaining: number) => void, + isTeacher = false ): () => void { let settled = false; let countdownInterval: ReturnType | null = null; @@ -104,22 +142,22 @@ export function initVelxioBridge( lockComponents: true }); - // Priority: Restore from localStorage if available, otherwise use data from backend - const savedCircuit = localStorage.getItem(arduinoCircuitKey); - const savedCode = localStorage.getItem(arduinoCodeKey); - - if (savedCircuit) { - console.log('[Velxio Bridge] Restoring circuit from draft'); - velxioBridge.loadCircuit(savedCircuit); - } else if (data.velxio_circuit) { - velxioBridge.loadCircuit(data.velxio_circuit); + // Priority: Restore from localStorage if modified by student on current baseline, + // otherwise use latest data from backend. + const restoredCircuit = resolveRestoredContent(arduinoCircuitKey, data.velxio_circuit, isTeacher); + if (restoredCircuit.content) { + if (restoredCircuit.shouldRestore) { + console.log('[Velxio Bridge] Restoring circuit from draft'); + } + velxioBridge.loadCircuit(restoredCircuit.content); } - if (savedCode) { - console.log('[Velxio Bridge] Restoring code from draft'); - velxioBridge.loadCode([{ name: 'sketch.ino', content: savedCode }]); - } else if (data.initial_code_arduino) { - velxioBridge.loadCode([{ name: 'sketch.ino', content: data.initial_code_arduino }]); + const restoredCode = resolveRestoredContent(arduinoCodeKey, data.initial_code_arduino, isTeacher); + if (restoredCode.content) { + if (restoredCode.shouldRestore) { + console.log('[Velxio Bridge] Restoring code from draft'); + } + velxioBridge.loadCode([{ name: 'sketch.ino', content: restoredCode.content }]); } } @@ -173,27 +211,23 @@ export function initVelxioBridge( lockComponents: true }, '*'); - const savedCircuit = localStorage.getItem(arduinoCircuitKey); - const savedCode = localStorage.getItem(arduinoCodeKey); - - if (savedCircuit) { + const restoredCircuit = resolveRestoredContent(arduinoCircuitKey, data.velxio_circuit, isTeacher); + if (restoredCircuit.content) { try { - console.log('[Velxio Fallback] Restoring circuit from draft'); - const circuitData = JSON.parse(savedCircuit); - win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*'); - } catch {} - } else if (data.velxio_circuit) { - try { - const circuitData = JSON.parse(data.velxio_circuit); + if (restoredCircuit.shouldRestore) { + console.log('[Velxio Fallback] Restoring circuit from draft'); + } + const circuitData = JSON.parse(restoredCircuit.content); win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*'); } catch {} } - if (savedCode) { - console.log('[Velxio Fallback] Restoring code from draft'); - win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: savedCode }] }, '*'); - } else if (data.initial_code_arduino) { - win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: data.initial_code_arduino }] }, '*'); + const restoredCode = resolveRestoredContent(arduinoCodeKey, data.initial_code_arduino, isTeacher); + if (restoredCode.content) { + if (restoredCode.shouldRestore) { + console.log('[Velxio Fallback] Restoring code from draft'); + } + win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: restoredCode.content }] }, '*'); } } } catch { /* cross-origin or not ready yet */ } diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 589bb77..9fdd687 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import { get } from 'svelte/store'; import { pickDefaultTab } from '$services/lesson-tabs'; import { tick, untrack } from 'svelte'; -import { auth, authLoggedIn, authToken } from '$stores/auth'; +import { auth, authLoggedIn, authToken, authIsTeacher } from '$stores/auth'; import { lessonContext } from '$stores/lessonContext'; import { compileCode, submitLessonProgress, lessonProgressBeacon, type LessonProgressPayload } from '$services/api'; import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity'; @@ -136,28 +136,32 @@ export class LessonManager { }; mql.addEventListener('change', handler); - // Auto-save Velxio state periodically + // Auto-save Velxio state periodically (only for logged-in students, not teachers) $effect(() => { - if (this.velxioReady && get(authLoggedIn) && !this.showSolution) { + if (this.velxioReady && get(authLoggedIn) && !get(authIsTeacher) && !this.showSolution) { const interval = setInterval(() => { const state = getVelxioState(this.velxioIframe); if (!state) return; let changed = false; - // 1. Source Code + // 1. Source Code: only save if modified from initial code + const initialCode = this.data?.initial_code_arduino ?? ''; const savedCode = localStorage.getItem(this.arduinoCodeKey); - if (state.code && state.code !== savedCode) { + if (state.code && state.code !== initialCode && state.code !== savedCode) { console.log('[Velxio Auto-save] Saving code changes (Zustand)'); localStorage.setItem(this.arduinoCodeKey, state.code); + localStorage.setItem(this.arduinoCodeKey + '_base', initialCode); changed = true; } - // 2. Circuit (Diagram + Wires) + // 2. Circuit (Diagram + Wires): only save if modified from initial circuit + const initialCircuit = this.data?.velxio_circuit ?? ''; const savedCircuit = localStorage.getItem(this.arduinoCircuitKey); - if (state.circuit && state.circuit !== savedCircuit) { + if (state.circuit && state.circuit !== initialCircuit && state.circuit !== savedCircuit) { console.log('[Velxio Auto-save] Saving circuit changes (Zustand)'); localStorage.setItem(this.arduinoCircuitKey, state.circuit); + localStorage.setItem(this.arduinoCircuitKey + '_base', initialCircuit); changed = true; } @@ -615,7 +619,9 @@ export class LessonManager { Object.assign(this.circuitOut, this.freshOutput()); } else if (this.activeTab === 'velxio') { localStorage.removeItem(this.arduinoCodeKey); + localStorage.removeItem(this.arduinoCodeKey + '_base'); localStorage.removeItem(this.arduinoCircuitKey); + localStorage.removeItem(this.arduinoCircuitKey + '_base'); if (this.data.initial_code_arduino) { this.velxioBridge?.loadCode([{ name: 'sketch.ino', content: this.data.initial_code_arduino }]); } @@ -688,7 +694,8 @@ export class LessonManager { error: '', success: null }); - } + }, + get(authIsTeacher) ); } diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index 6ba2df4..aa0cd12 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -680,7 +680,7 @@ - {/if} diff --git a/routes/content_editor.py b/routes/content_editor.py index e94a824..a31af23 100644 --- a/routes/content_editor.py +++ b/routes/content_editor.py @@ -31,7 +31,7 @@ from flask import Blueprint, jsonify, request from config import ASSETS_DIR, CONTENT_DIR from services import repositories from services.database import SessionLocal -from services.lesson_service import _render_markdown_string +from services.lesson_service import _render_markdown_string, invalidate_lesson_caches from services.teacher_auth import check_origin, teacher_user_from_cookie content_editor_bp = Blueprint("content_editor", __name__, url_prefix="/content") @@ -215,6 +215,7 @@ def publish(draft_id): }), 500 repositories.mark_draft_published(db, draft=draft) + invalidate_lesson_caches(abs_path) return jsonify({"success": True, "published_path": target_path}) finally: db.close() @@ -347,6 +348,7 @@ def create_file(): f.write(template) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa menulis file"}), 403 + invalidate_lesson_caches(abs_path) return jsonify({"success": True, "path": rel}) finally: db.close() @@ -396,6 +398,8 @@ def rename_entry(): if root == "content": repositories.retarget_drafts(db, old_prefix=old_rel, new_prefix=new_rel) + invalidate_lesson_caches(old_abs) + invalidate_lesson_caches(new_abs) return jsonify({"success": True, "path": new_rel}) finally: db.close() @@ -444,6 +448,7 @@ def delete_entry(): if root == "content": repositories.drop_drafts_under(db, path=rel) + invalidate_lesson_caches(abs_path) return jsonify({"success": True}) finally: db.close() diff --git a/services/lesson_service.py b/services/lesson_service.py index 9a2d756..2ee838e 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -63,25 +63,42 @@ def _read_md_cached(path): with _file_cache_lock: _file_cache[path] = {'content': content, 'mtime': current_mtime} - # If this is the root home.md, invalidate downstream caches - if os.path.abspath(path) == _HOME_MD_PATH: - find_lesson_file.cache_clear() - get_lessons.cache_clear() - get_lesson_names.cache_clear() - get_lessons_with_learning_objectives.cache_clear() - with _markdown_lock: - _markdown_cache.clear() - - # If this is a sub-home.md, invalidate its parsed data cache so edits to - # the file (new lessons, reorder) show up without restarting the app. - if os.path.basename(path) == 'sub-home.md': - with _sub_home_cache_lock: - _sub_home_cache.clear() - find_lesson_file.cache_clear() + # 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")