Crank dat!

This commit is contained in:
purplebored committed 2026-11-08 19:28:03 +01:00
commit fe13a4d989
17 files changed
+6624

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
/* ==========================================================================
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 = { "&": "&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>: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);
}