// 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();
}
}