Files

489 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
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 = `<div class="loading"><span class="spinner">|</span> ${esc(label)}</div>`;
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 = `
<p><b>librus-tui</b> is a keyboard-first terminal frontend for the Librus Synergia
student portal. Everything is rendered from the Synergia gateway API.</p>
<div class="help-section">global keys</div>
<dl class="help-grid">
<dt>1 … 7</dt><dd>jump to a section</dd>
<dt>r</dt><dd>refresh the current section</dd>
<dt>:</dt><dd>open the command line</dd>
<dt>?</dt><dd>this help window</dd>
<dt>q</dt><dd>log out</dd>
<dt>j / k</dt><dd>scroll, or move through the inbox</dd>
<dt>g / G</dt><dd>jump to top / bottom</dd>
</dl>
<div class="help-section">sections</div>
<dl class="help-grid">
<dt>grades</dt><dd><kbd>/</kbd> filter · click a grade for details</dd>
<dt>timetable</dt><dd><kbd>[</kbd> previous week · <kbd>]</kbd> next week</dd>
<dt>homework</dt><dd>click a row for the full description</dd>
<dt>messages</dt><dd><kbd>j</kbd>/<kbd>k</kbd> select · <kbd>Enter</kbd> open · attachments download</dd>
</dl>
<div class="help-section">commands</div>
<dl class="help-grid">
<dt>:dashboard :grades …</dt><dd>jump to any section (aliases: d, g, t, h, a, m, f)</dd>
<dt>:week 0..3</dt><dd>select a timetable week</dd>
<dt>:reload</dt><dd>refresh the current section</dd>
<dt>:status</dt><dd>print session information</dd>
<dt>:clear</dt><dd>clear the screen</dd>
<dt>:fullscreen / :fs</dt><dd>toggle browser fullscreen</dd>
<dt>:settings</dt><dd>open notification settings</dd>
<dt>:logout</dt><dd>end the Synergia session</dd>
</dl>
<div class="help-section">about</div>
<p>Synergia client adapted from
<a href="https://github.com/dani3l0/librusik" target="_blank" rel="noopener noreferrer">dani3l0/librusik</a>
(<code>lib/api/librus.py</code>, stable).<br>
Your credentials are exchanged directly with Librus and are never written to disk.</p>
`;
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);
}