// Every state-changing call carries the per-session CSRF token from the // tag in _shell.html; the API rejects a POST without it. function csrfHeaders(extra) { const meta = document.querySelector('meta[name="csrf-token"]'); return Object.assign({}, extra || {}, { "X-CSRF-Token": meta ? meta.content : "" }); } // Single door for the JSON API. Returns {ok} on success, or // {ok: false, error, handled} — `handled` meaning it already told the // user and is navigating away, so the caller should stay quiet. // A dead login session answers 401 rather than redirecting (see // login_required in app.py), which is what makes it distinguishable // from a real success here. async function apiPost(url, body) { const headers = csrfHeaders(body ? { "Content-Type": "application/json" } : {}); let res; try { res = await fetch(url, { method: "POST", headers, body: body ? JSON.stringify(body) : undefined, }); } catch (err) { return { ok: false, error: "Couldn't reach the server" }; } if (res.status === 401) { alert("Your login session expired — returning to the login page."); window.location.reload(); return { ok: false, error: "signed out", handled: true }; } if (res.ok) return { ok: true }; const data = await res.json().catch(() => ({})); return { ok: false, error: data.error || `Request failed (${res.status})` }; } document.addEventListener("DOMContentLoaded", () => { const refreshBtn = document.getElementById("refresh-rules"); if (refreshBtn) { refreshBtn.addEventListener("click", async () => { refreshBtn.disabled = true; const original = refreshBtn.textContent; refreshBtn.textContent = "Refreshing…"; try { const out = await apiPost("/api/rules/refresh"); if (!out.ok) { if (!out.handled) alert("Refresh failed: " + out.error); return; } window.location.reload(); } finally { refreshBtn.disabled = false; refreshBtn.textContent = original; } }); } document.querySelectorAll(".rule-card").forEach(initRuleCard); initSessionControls(); }); function initSessionControls() { const importBtn = document.getElementById("session-import"); const textarea = document.getElementById("cookie-paste"); if (!importBtn) return; // not on the Session page importBtn.addEventListener("click", async () => { const original = importBtn.textContent; importBtn.disabled = true; importBtn.textContent = "Verifying…"; try { const out = await apiPost("/api/session/import", { cookies: textarea.value }); if (!out.ok) { if (!out.handled) alert(out.error); return; } window.location.reload(); } finally { importBtn.disabled = false; importBtn.textContent = original; } }); } function initRuleCard(card) { const ruleId = card.dataset.ruleId; card.querySelector(".rule-enabled").addEventListener("change", (e) => { saveSchedule(card, { enabledOverride: e.target.checked }); }); card.querySelectorAll(".day-toggle[data-day]").forEach((btn) => { btn.addEventListener("click", () => btn.classList.toggle("active")); }); const allDaysBtn = card.querySelector(".day-toggle-all"); if (allDaysBtn) { allDaysBtn.addEventListener("click", () => { card.querySelectorAll(".day-toggle[data-day]").forEach((btn) => btn.classList.add("active")); }); } card.querySelector(".add-schedule").addEventListener("click", () => { const time = card.querySelector(".new-time").value; const days = [...card.querySelectorAll(".day-toggle[data-day].active")].map((b) => b.dataset.day); if (!time || days.length === 0) { alert("Pick a time and at least one day."); return; } addChip(card, time, days); card.querySelectorAll(".day-toggle[data-day].active").forEach((b) => b.classList.remove("active")); saveSchedule(card); }); card.querySelectorAll(".chip-remove").forEach((btn) => bindRemove(btn)); card.querySelector(".run-now").addEventListener("click", async (e) => { const btn = e.currentTarget; const original = btn.textContent; btn.disabled = true; btn.textContent = "Queuing…"; try { const out = await apiPost(`/api/rules/${ruleId}/run`); btn.textContent = out.ok ? "Queued" : "Failed"; if (!out.ok && !out.handled) alert("Could not queue that run: " + out.error); } finally { setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000); } }); } function bindRemove(btn) { btn.addEventListener("click", () => { const card = btn.closest(".rule-card"); btn.closest(".schedule-chip").remove(); saveSchedule(card); }); } function addChip(card, time, days) { const list = card.querySelector(".schedule-list"); const li = document.createElement("li"); li.className = "schedule-chip"; li.innerHTML = `${time}` + `${days.join(" ")}` + ``; list.appendChild(li); bindRemove(li.querySelector(".chip-remove")); } async function saveSchedule(card, opts = {}) { const ruleId = card.dataset.ruleId; const enabled = opts.enabledOverride !== undefined ? opts.enabledOverride : card.querySelector(".rule-enabled").checked; const schedules = [...card.querySelectorAll(".schedule-chip")].map((chip) => ({ time: chip.querySelector(".chip-time").textContent.trim(), days: chip .querySelector(".chip-days") .textContent.trim() .split(/\s+/) .filter(Boolean), })); const out = await apiPost(`/api/rules/${ruleId}/schedule`, { enabled, schedules }); if (!out.ok && !out.handled) { alert("Schedule not saved: " + out.error); // The card was already updated optimistically, so reload to put // what's on screen back in sync with what's actually stored — // otherwise a rejected schedule looks exactly like a saved one. window.location.reload(); } }