/* ========================================================================== librus-tui frontend - core: state, helpers, API, chrome ========================================================================== */ "use strict"; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays"]; const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const VIEW_TITLES = { dashboard: "dashboard", grades: "grades", timetable: "timetable", homework: "homework & tests", attendance: "attendance", messages: "messages", freedays: "free days", }; const VIEW_ALIASES = { d: "dashboard", dash: "dashboard", dashboard: "dashboard", g: "grades", grades: "grades", oceny: "grades", t: "timetable", plan: "timetable", timetable: "timetable", h: "homework", hw: "homework", exams: "homework", homework: "homework", sprawdziany: "homework", a: "attendance", attendance: "attendance", frekwencja: "attendance", m: "messages", msg: "messages", messages: "messages", wiadomosci: "messages", f: "freedays", free: "freedays", freedays: "freedays", dni: "freedays", }; const state = { config: { demo: false, version: "1.0.0" }, session: null, view: "dashboard", loadToken: 0, messageToken: 0, loginBusy: false, timetableWeek: 0, gradeFilter: "", gradeGroups: [], gradesFlat: [], exams: [], messages: [], messageIndex: -1, messageCache: new Map(), commandHistory: [], commandPosition: -1, lastSync: null, }; let spinnerTimer = null; let bootTimer = null; /* ------------------------------------------------------------------ helpers */ const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]); } function personName(person) { if (!person) return ""; return `${person.FirstName || ""} ${person.LastName || ""}`.trim(); } function parseDate(value) { if (!value) return null; const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); if (!match) return null; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); if (!year || !month || !day) return null; return new Date(year, month - 1, day); } function isoDate(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function fmtDate(value, withYear = true) { const date = parseDate(value); if (!date) return "—"; const day = String(date.getDate()).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); return withYear ? `${day}.${month}.${date.getFullYear()}` : `${day}.${month}`; } function fmtDateShort(value) { return fmtDate(value, false); } function weekdayShort(value) { const date = parseDate(value); if (!date) return ""; return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getDay()]; } function daysUntil(value) { const date = parseDate(value); if (!date) return null; const today = new Date(); today.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0); return Math.round((date - today) / 86400000); } function countdownLabel(value) { const days = daysUntil(value); if (days === null) return ""; if (days === 0) return "today"; if (days === 1) return "tomorrow"; if (days === -1) return "yesterday"; if (days > 1) return `in ${days} days`; return `${-days} days ago`; } function countdownClass(value) { const days = daysUntil(value); if (days === 0) return "now"; if (days !== null && days > 0 && days <= 7) return "soon"; return ""; } function shortTime(value) { if (!value) return ""; const match = String(value).match(/^(\d{1,2}):(\d{2})/); if (!match) return String(value); return `${match[1].padStart(2, "0")}:${match[2]}`; } function timeRange(from, to) { const start = shortTime(from); const end = shortTime(to); if (start && end) return `${start}–${end}`; return start || end || "—"; } function timeToMinutes(value) { const match = String(value || "").match(/^(\d{1,2}):(\d{2})/); if (!match) return null; return Number(match[1]) * 60 + Number(match[2]); } function truncate(value, length = 90) { const text = String(value ?? ""); if (text.length <= length) return text; return `${text.slice(0, Math.max(0, length - 1))}…`; } function titleCase(value) { const text = String(value ?? ""); return text ? text.charAt(0).toUpperCase() + text.slice(1) : ""; } function weightNumber(weight) { const number = Number.parseFloat(String(weight ?? "").replace(",", ".")); return Number.isFinite(number) && number > 0 ? number : 1; } function numericGrade(grade) { const number = Number.parseFloat(String(grade ?? "").replace(",", ".")); return Number.isFinite(number) ? number : null; } function gradeClass(grade) { const number = numericGrade(grade); if (number === null) return "g-none"; if (number >= 5) return "g-excellent"; if (number >= 4) return "g-good"; if (number >= 3) return "g-ok"; return "g-bad"; } function subjectAverage(grades) { let total = 0; let weights = 0; for (const grade of grades) { const value = numericGrade(grade.Grade); if (value === null) continue; const weight = weightNumber(grade.Weight); total += value * weight; weights += weight; } return weights ? total / weights : null; } function averageLabel(grades) { const average = subjectAverage(grades); return average === null ? "—" : average.toFixed(2); } function examTagClass(type) { const text = String(type || "").toLowerCase(); if (text.includes("sprawdzian") || text.includes("test")) return "tag-red"; if (text.includes("kartk") || text.includes("quiz")) return "tag-amber"; if (text.includes("projekt") || text.includes("wyprac") || text.includes("esej")) return "tag-cyan"; return "tag-dim"; } function schoolAddress(school) { if (!school) return "—"; const parts = [school.Street, school.BuildingNumber].filter(Boolean).join(" "); const city = [school.PostCode, school.Town].filter(Boolean).join(" "); return [parts, city].filter(Boolean).join(", ") || school.Name || "—"; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /* ---------------------------------------------------------------------- API */ class ApiError extends Error { constructor(message, status = 0) { super(message); this.name = "ApiError"; this.status = status; } } async function api(path, options = {}) { const opts = { credentials: "same-origin", ...options }; if (opts.body && typeof opts.body !== "string") { opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; opts.body = JSON.stringify(opts.body); } let response; try { response = await fetch(path, opts); } catch (error) { throw new ApiError("Network error: cannot reach the librus-tui server.", 0); } let data = null; try { data = await response.json(); } catch (error) { data = null; } if (!response.ok) { if ( response.status === 401 && state.session && state.session.authenticated && !path.startsWith("/api/login") ) { handleExpiredSession(); } const message = (data && data.error) || `Request failed (HTTP ${response.status}).`; throw new ApiError(message, response.status); } return data; } /* ------------------------------------------------------------ chrome helpers */ function setLed(kind) { const led = $("#connection-led"); if (!led) return; led.classList.remove("online", "busy", "offline"); if (kind) led.classList.add(kind); } function setStatus(text, kind = "") { const element = $("#status-left"); if (!element) return; element.textContent = text; element.className = `status-left${kind ? ` ${kind}` : ""}`; } function setMeta(text) { const element = $("#view-meta"); if (element) element.textContent = text; } function syncTime() { return new Date().toLocaleTimeString("en-GB", { hour12: false }); } function setViewReady(note) { stopSpinner(); setLed("online"); state.lastSync = new Date(); const label = note || `loaded ${VIEW_TITLES[state.view] || state.view}`; setMeta(`synced ${syncTime()}`); setStatus(`[ok] ${label}`, "ok"); const statusRight = $("#status-right"); if (statusRight) statusRight.textContent = "synergia://connected"; } function startSpinner() { stopSpinner(); const frames = "|/-\\"; let index = 0; spinnerTimer = setInterval(() => { $$(".spinner").forEach((element) => { element.textContent = frames[index % frames.length]; }); index += 1; }, 110); } function stopSpinner() { if (spinnerTimer) { clearInterval(spinnerTimer); spinnerTimer = null; } } function setViewLoading(label) { const view = $("#view"); if (!view) return; view.innerHTML = `
librus-tui is a keyboard-first terminal frontend for the Librus Synergia student portal. Everything is rendered from the Synergia gateway API.
Synergia client adapted from
dani3l0/librusik
(lib/api/librus.py, stable).
Your credentials are exchanged directly with Librus and are never written to disk.