/* ========================================================================== 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", "settings"];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", settings: "settings", }; 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", s: "settings", settings: "settings", config: "settings", }; const state = { config: { demo: false, version: "1.0.0" }, session: null, view: "dashboard", loadToken: 0, messageToken: 0, loginBusy: false, notificationPoller: null, lastNotifySnapshot: null, settingsLog: [], 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 = `
| ${esc(label)}
`; setMeta("contacting synergia…"); setStatus(`[...] ${label}`); setLed("busy"); startSpinner(); } function toast(message, kind = "ok", duration = 3800) { const root = $("#toasts"); if (!root) return; const element = document.createElement("div"); element.className = `toast ${kind}`; const prefix = document.createElement("span"); prefix.className = "toast-prefix"; prefix.textContent = kind === "err" ? "!!" : kind === "warn" ? "!" : ">>"; element.appendChild(prefix); element.appendChild(document.createTextNode(message)); root.appendChild(element); setTimeout(() => { element.remove(); }, duration); } /* --------------------------------------------------------------------- modal */ function openModal(title, bodyHtml, actions = []) { const root = $("#modal-root"); const titleElement = $("#modal-title"); const bodyElement = $("#modal-body"); const actionsElement = $("#modal-actions"); if (!root || !titleElement || !bodyElement || !actionsElement) return; titleElement.textContent = title; bodyElement.innerHTML = bodyHtml; actionsElement.innerHTML = ""; for (const action of actions) { const button = document.createElement("button"); button.type = "button"; button.className = `btn${action.kind ? ` ${action.kind}` : ""}`; button.textContent = action.label; if (action.close) { button.dataset.modalClose = ""; } else if (action.action) { button.dataset.action = action.action; } actionsElement.appendChild(button); } root.classList.remove("hidden"); const box = $("#modal-box"); if (box) box.focus(); } function closeModal() { const root = $("#modal-root"); if (root) root.classList.add("hidden"); } function openHelpModal() { const body = `

librus-tui is a keyboard-first terminal frontend for the Librus Synergia student portal. Everything is rendered from the Synergia gateway API.

global keys
1 … 7
jump to a section
r
refresh the current section
:
open the command line
?
this help window
q
log out
j / k
scroll, or move through the inbox
g / G
jump to top / bottom
sections
grades
/ filter · click a grade for details
timetable
[ previous week · ] next week
homework
click a row for the full description
messages
j/k select · Enter open · attachments download
commands
:dashboard :grades …
jump to any section (aliases: d, g, t, h, a, m, f)
:week 0..3
select a timetable week
:reload
refresh the current section
:status
print session information
:clear
clear the screen
:fullscreen / :fs
toggle browser fullscreen
:settings
open notification settings
:logout
end the Synergia session
about

Synergia client adapted from dani3l0/librusik (lib/api/librus.py, stable).
Your credentials are exchanged directly with Librus and are never written to disk.

`; openModal("help", body, [{ label: "[ close ]", close: true }]); } /* ----------------------------------------------------------------- boot log */ function appendBoot(kind, text) { const log = $("#bootlog"); if (!log) return; const line = document.createElement("span"); if (kind) line.className = kind; line.textContent = `${text}\n`; log.appendChild(line); log.scrollTop = log.scrollHeight; } function clearBoot() { const log = $("#bootlog"); if (log) log.innerHTML = ""; } function stopBootTimer() { if (bootTimer) { clearInterval(bootTimer); bootTimer = null; } } function startBootTimer() { stopBootTimer(); const lines = [ ["info", " $ ssh synergia.librus.pl"], ["cmd", " GET /loguj/portalRodzina"], ["cmd", " POST api.librus.pl/OAuth/Authorization?client_id=46"], ["cmd", " GET /OAuth/Authorization/2FA?client_id=46"], ["cmd", " waiting for DZIENNIKSID / SDZIENNIKSID …"], ["cmd", " probing /gateway/api/2.0/Auth/TokenInfo …"], ]; let index = 0; bootTimer = setInterval(() => { if (index < lines.length) { appendBoot(lines[index][0], lines[index][1]); index += 1; } else { stopBootTimer(); } }, 360); }