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
This commit is contained in:
a2nr 2026-09-06 16:17:35 +07:00
parent 76e0210a9d
commit 7d590e0f95
9 changed files with 245 additions and 59 deletions

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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<string, string> = {};
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();
});
});
});

View File

@ -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<typeof setInterval> | 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 */ }

View File

@ -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)
);
}

View File

@ -680,7 +680,7 @@
<button class="panel-btn save-btn" onclick={() => handleSaveAction()} disabled={mgr.saving || (!mgr.dirty && mgr.activeTab !== 'velxio')} title="Simpan">
{mgr.saving ? '⏳' : '💾'}
</button>
<button class="panel-btn publish-btn" onclick={() => handlePublishAction()} disabled={mgr.publishing || !mgr.draftId} title="Publish">
<button class="panel-btn publish-btn" onclick={() => handlePublishAction()} disabled={mgr.publishing || mgr.saving} title="Publish">
{mgr.publishing ? '⏳' : '🚀'}
</button>
{/if}

View File

@ -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()

View File

@ -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")