489 lines
14 KiB
JavaScript
489 lines
14 KiB
JavaScript
/* ==========================================================================
|
||
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 = `<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);
|
||
}
|