// The lesson editor (add-subjects-and-worksheets): a worksheet's pages beside the lesson drafted from it, every part editable.
// The lesson's name; its skills (name, short name, one-line description); each skill's multiple-choice questions with their
// choices, which one is right, and for each wrong one the mistake it shows; the lesson's mistakes (name and tip). Every change is
// saved (the whole document, a moment after the last keystroke), so leaving and coming back finds the draft as it was. A question
// a person edits or adds is marked "written by a person"; the AI's own are marked so a reviewer checks them first. Nothing here
// reaches a student: publishing (with its checks) is separate.
import { api } from "./api.js";
import { esc, fmt } from "./math.js";
import { token } from "./session.js";
const SOURCE = { sheet: "מהדף", ai: "נכתב על ידי AI", person: "נכתב על ידי אדם" };
const SAVE_AFTER = 700;
let state = null; // {id, view, doc, pages: [object URLs], text, saving, timer, onBack, poll}
/** A page picture needs the sign-in header, so it is fetched and shown from memory. */
async function pageUrl(lessonId, index) {
const response = await fetch(`/api/worksheets/${lessonId}/pages/${index}`, { headers: { Authorization: "Bearer " + await token() } });
return response.ok ? URL.createObjectURL(await response.blob()) : "";
}
const at = (doc, path) => path.split(".").reduce((node, key) => node?.[/^\d+$/.test(key) ? Number(key) : key], doc);
function put(doc, path, value) {
const keys = path.split("."), last = keys.pop();
const parent = keys.length ? at(doc, keys.join(".")) : doc;
if (parent) parent[/^\d+$/.test(last) ? Number(last) : last] = value;
}
function mistakeOptions(chosen) {
const entries = Object.entries(state.doc.mistakes || {});
return `— בחרו טעות — ${entries.map(([id, m]) =>
`${esc(m.name || "טעות בלי שם")} `).join("")}`;
}
function choiceRow(path, choice, ci, qi) {
const name = `right-${path.replace(/\./g, "-")}`;
return `
נכונה
${choice.correct ? "" : `${mistakeOptions(choice.mistake)} `}
✕ `;
}
function questionCard(si, qi, q) {
const path = `skills.${si}.questions.${qi}`;
return `
שאלה ${qi + 1} ${SOURCE[q.source] || SOURCE.person}
▲ ▼
מחיקה
${(q.choices || []).map((c, ci) => choiceRow(path, c, ci, qi)).join("")}
+ תשובה
${fmt(q.question || "")}
${(q.choices || []).map(c => `${fmt(c.text || "")} `).join("")} `;
}
function skillCard(skill, si) {
const path = `skills.${si}`;
return ``;
}
function mistakesCard() {
return `טעויות כל תשובה שגויה מראה לתלמיד טעות: שם קצר וטיפ איך לא לטעות שוב.
+ טעות
`;
}
function statusPanel() {
const v = state.view;
if (v.status === "drafting") return `מכינים טיוטה מדף העבודה. זה יכול לקחת דקה או שתיים…
`;
if (v.status === "failed" && v.failure) return `${esc(v.failure.what)}
${v.failure.note ? `
${esc(v.failure.note)}
` : ""}
${esc(v.failure.todo)}
נסו שוב `;
return "";
}
function pagesColumn() {
const pages = state.pages.map((url, i) => url ? ` ` : "").join("");
return `${pages}${state.text ? `${esc(state.text)} ` : ""}
${!pages && !state.text ? `אין דף עבודה לשיעור הזה.
` : ""} `;
}
function publishPanel() {
if (!state.doc) return "";
const problems = state.problems || [];
return `${state.published ? "השיעור פורסם לתלמידים" : "טיוטה: התלמידים עוד לא רואים את השיעור"}
${state.published ? "פרסום השינויים" : "פרסום"}
${state.published ? `ביטול הפרסום ` : ""}
${state.publishMessage ? `${esc(state.publishMessage)}
` : ""}
${problems.length ? `${esc(state.problemsTitle || "עוד לא אפשר לפרסם:")} ${problems.map(p => `${esc(p)} `).join("")} ` : ""} `;
}
function html() {
const doc = state.doc;
return `
‹ חזרה למקצועות
${statusPanel()}${publishPanel()}
${pagesColumn()}
${doc ? `
${(doc.skills || []).map(skillCard).join("")}
+ מיומנות
${mistakesCard()}` : ""}
`;
}
function draw() {
const root = document.getElementById("lesson-editor-root");
if (!root) return;
const focused = document.activeElement?.dataset?.f, caret = document.activeElement?.selectionStart;
root.innerHTML = html();
if (focused) {
const again = root.querySelector(`[data-f="${CSS.escape(focused)}"]`);
again?.focus();
if (again && caret != null && again.setSelectionRange) try { again.setSelectionRange(caret, caret); } catch { /* not a text field */ }
}
}
function said(text) { const el = document.getElementById("le-saved"); if (el) el.textContent = text; }
async function saveNow() {
clearTimeout(state.timer);
state.timer = null;
said("שומרים…");
try {
const saved = await api(`/api/subjects/lessons/${state.id}/draft`, { method: "PUT", body: { lesson: state.doc } });
const fresh = saved.draft;
const renamed = JSON.stringify(Object.keys(fresh.mistakes || {})) !== JSON.stringify(Object.keys(state.doc.mistakes || {}))
|| (fresh.skills || []).some((s, i) => s.id !== state.doc.skills[i]?.id);
state.doc = fresh;
if (renamed) draw(); // new skills or mistakes got their ids: the selects must name them
said("נשמר");
} catch (e) { said(""); const el = document.getElementById("le-saved"); if (el) { el.textContent = `לא נשמר: ${e.message}`; el.classList.add("error"); } }
}
function changed({ redraw = false } = {}) {
if (redraw) draw();
said("");
clearTimeout(state.timer);
state.timer = setTimeout(saveNow, SAVE_AFTER);
}
const asPerson = question => { if (question) question.source = "person"; };
const move = (list, from, to) => { if (to < 0 || to >= list.length) return; const [item] = list.splice(from, 1); list.splice(to, 0, item); };
const newMistake = () => { const id = `new-${Date.now()}`; state.doc.mistakes = { ...(state.doc.mistakes || {}), [id]: { name: "", tip: "" } }; return id; };
function onInput(event) {
const field = event.target.closest("[data-f]");
if (!field || !state?.doc) return;
const path = field.dataset.f;
put(state.doc, path, field.value);
const question = path.match(/^skills\.\d+\.questions\.\d+/);
if (question) asPerson(at(state.doc, question[0]));
const preview = field.closest(".le-question");
if (preview) { // the question's preview follows the typing, without redrawing the field
const q = at(state.doc, preview.dataset.path);
preview.querySelector(".le-preview").innerHTML = `${fmt(q.question || "")}
${(q.choices || []).map(c => `${fmt(c.text || "")} `).join("")} `;
const badge = preview.querySelector(".le-source");
badge.className = "le-source le-person"; badge.textContent = SOURCE.person;
}
changed({ redraw: event.type === "change" && field.tagName === "SELECT" });
}
async function publish() {
if (state.timer) await saveNow();
try {
const done = await api(`/api/subjects/lessons/${state.id}/publish`, { method: "POST" });
Object.assign(state, { published: true, problems: [], publishMessage: "השיעור פורסם. התלמידים של הכיתות שלומדות את המקצוע רואים אותו עכשיו.", doc: done.draft });
} catch (e) {
Object.assign(state, { problems: e.problems || [e.message], problemsTitle: "עוד לא אפשר לפרסם:", publishMessage: "" });
}
draw();
document.getElementById("le-problems")?.scrollIntoView({ block: "nearest" });
}
async function unpublish() {
if (!confirm("לבטל את הפרסום? השיעור ייעלם מהמסלול של התלמידים, וההתקדמות שלהם תישמר.")) return;
try {
await api(`/api/subjects/lessons/${state.id}/unpublish`, { method: "POST" });
Object.assign(state, { published: false, problems: [], publishMessage: "הפרסום בוטל. השיעור חזר להיות טיוטה." });
} catch (e) { Object.assign(state, { problems: [e.message], problemsTitle: "אי אפשר לבטל את הפרסום:", publishMessage: "" }); }
draw();
}
function onClick(event) {
const button = event.target.closest("[data-act]");
if (event.target.closest("#le-back")) { leave(); return; }
if (event.target.closest("#le-do-publish")) { publish(); return; }
if (event.target.closest("#le-unpublish")) { unpublish(); return; }
if (event.target.closest("#le-retry")) { retry(); return; }
if (!button || !state?.doc) return;
const doc = state.doc, act = button.dataset.act;
const skillPath = button.closest(".le-skill")?.dataset.path, qPath = button.closest(".le-question")?.dataset.path;
const skills = doc.skills = doc.skills || [];
const si = skillPath ? Number(skillPath.split(".")[1]) : -1, qi = qPath ? Number(qPath.split(".")[3]) : -1;
const questions = si >= 0 ? (skills[si].questions = skills[si].questions || []) : [];
if (act === "right") {
const choices = at(doc, qPath).choices, ci = Number(button.closest(".le-choice").dataset.path.split(".").pop());
choices.forEach((c, i) => { if (i === ci) { c.correct = true; delete c.mistake; } else if (c.correct) { delete c.correct; c.mistake = ""; } });
asPerson(at(doc, qPath));
} else if (act === "del-choice") {
at(doc, qPath).choices.splice(Number(button.closest(".le-choice").dataset.path.split(".").pop()), 1); asPerson(at(doc, qPath));
} else if (act === "add-choice") { at(doc, qPath).choices = [...(at(doc, qPath).choices || []), { text: "", mistake: "" }]; asPerson(at(doc, qPath)); }
else if (act === "add-q") questions.push({ question: "", source: "person", choices: [{ text: "", correct: true }, { text: "", mistake: "" }] });
else if (act === "del-q") { if (!confirm("למחוק את השאלה?")) return; questions.splice(qi, 1); }
else if (act === "up-q" || act === "down-q") move(questions, qi, qi + (act === "up-q" ? -1 : 1));
else if (act === "add-s") skills.push({ name: "", short: "", description: "", questions: [] });
else if (act === "del-s") { if (!confirm("למחוק את המיומנות ואת השאלות שלה?")) return; skills.splice(si, 1); }
else if (act === "up-s" || act === "down-s") move(skills, si, si + (act === "up-s" ? -1 : 1));
else if (act === "add-mistake") newMistake();
else return;
changed({ redraw: true });
}
async function refresh() {
const v = await api(`/api/worksheets/${state.id}`).catch(() => null);
if (!v || !state) return;
const was = state.view.status;
state.view = v;
if (v.status !== "drafting") {
clearInterval(state.poll); state.poll = null;
if (was === "drafting") { state.doc = (await api(`/api/subjects/lessons/${state.id}`)).draft; draw(); }
}
}
async function retry() {
try { state.view = await api(`/api/worksheets/${state.id}/draft`, { method: "POST" }); draw(); watch(); }
catch (e) { said(e.message); }
}
function watch() {
clearInterval(state.poll);
if (state.view.status === "drafting") state.poll = setInterval(refresh, 2000);
}
function leave() {
const back = state.onBack;
if (state.timer) saveNow();
clearInterval(state.poll);
state.pages.forEach(url => url && URL.revokeObjectURL(url));
state = null;
back();
}
/** Open the editor for a lesson inside `root` (an element the caller drew). `onBack` returns to where the person came from. */
export async function open(root, lessonId, onBack) {
root.id = "lesson-editor-root";
const lesson = await api(`/api/subjects/lessons/${lessonId}`);
const view = lesson.source === "worksheet" ? await api(`/api/worksheets/${lessonId}`) : { status: "ready", pages: [], has_text: false };
state = { id: lessonId, view, doc: view.status === "ready" ? lesson.draft : null, pages: [], text: "", timer: null, poll: null, onBack,
published: lesson.published, problems: [], publishMessage: "" };
if (state.doc && !Object.keys(state.doc).length) state.doc = { name: "", skills: [], mistakes: {} };
state.pages = await Promise.all(view.pages.map(i => pageUrl(lessonId, i)));
if (view.has_text) state.text = (await api(`/api/worksheets/${lessonId}/text`)).text;
draw();
root.addEventListener("input", onInput);
root.addEventListener("change", onInput);
root.addEventListener("click", onClick);
watch();
}