Crank dat!
This commit is contained in:
17 files changed
+6624
No files matched your search
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user