From fe13a4d989287a57816e0c00dfbcc1869f1cd704 Mon Sep 17 00:00:00 2001 From: ~purplebored Date: Sun, 8 Nov 2026 19:28:03 +0100 Subject: [PATCH] Crank dat! --- .gitignore | 4 + README.md | 144 +++ app.py | 631 +++++++++++ demo.py | 563 ++++++++++ librus.py | 722 ++++++++++++ requirements.txt | 2 + run.sh | 14 + static/favicon.svg | 10 + static/index.html | 185 ++++ static/js/00-core.js | 481 ++++++++ static/js/10-auth.js | 264 +++++ static/js/20-dashboard.js | 220 ++++ static/js/22-grades.js | 167 +++ static/js/24-timetable.js | 182 +++ static/js/26-more-views.js | 385 +++++++ static/js/30-app.js | 493 ++++++++ static/style.css | 2157 ++++++++++++++++++++++++++++++++++++ 17 files changed, 6624 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 demo.py create mode 100644 librus.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 static/favicon.svg create mode 100644 static/index.html create mode 100644 static/js/00-core.js create mode 100644 static/js/10-auth.js create mode 100644 static/js/20-dashboard.js create mode 100644 static/js/22-grades.js create mode 100644 static/js/24-timetable.js create mode 100644 static/js/26-more-views.js create mode 100644 static/js/30-app.js create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eaf51aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..693fc73 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# librus-tui + +A terminal-looking web frontend for **Librus Synergia**, built on the async +Synergia client from [dani3l0/librusik](https://github.com/dani3l0/librusik) +(`lib/api/librus.py`, stable branch). + +The whole UI is a single-page phosphor-green "TUI" in the browser: keyboard +navigation, command bar, box-drawn panels, live clock, scanlines and an OAuth +boot log. The Python backend talks to Synergia and keeps your Librus cookies +server-side; the browser only gets an opaque session cookie. + +``` + _ ___ ____ ____ _ _ ____ +| | |_ _| __ )| _ \| | | / ___| +| | | || _ \| |_) | | | \___ \ +| |___ | || |_) | _ <| |_| |___) | +|_____|___|____/|_| \_\\___/|____/ // TUI +``` + +## Quick start + +```bash +./run.sh # real Librus account, http://127.0.0.1:8765 +./run.sh --demo # offline sample data, no Librus account needed +``` + +`run.sh` creates `.venv`, installs the two dependencies, and starts the server. +To run it manually: + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python app.py --demo +``` + +Then open . In demo mode use the +`[ enter demo mode ]` button; in real mode log in with your Synergia +credentials. + +CLI options: + +| option | default | description | +| --- | --- | --- | +| `--host` | `127.0.0.1` | bind address | +| `--port` | `8765` | HTTP port | +| `--demo` | off | use the offline dataset | +| `--debug` | off | verbose logs / access log | + +Environment equivalents: `LIBRUS_HOST`, `LIBRUS_PORT`, `LIBRUS_DEMO=1`. + +## Views + +| key | view | contents | +| --- | --- | --- | +| `1` | dashboard | profile, lucky number, behaviour, counters, today's lessons, upcoming breaks | +| `2` | grades | weighted subject averages, colour-coded grade chips, comments, teacher, date | +| `3` | timetable | week grid, substitutions, cancellations, extra activities | +| `4` | homework | tests and homework sorted by date, full descriptions | +| `5` | attendance | register with absences, lateness and excused lessons | +| `6` | messages | inbox + reader + attachment downloads | +| `7` | free days | school/teacher free days and parent-teacher conferences | + +### Keyboard + +- `1`–`7` — switch view +- `j` / `k`, arrows — scroll; in messages move through the inbox +- `g` / `G` — top / bottom +- `r` — refresh +- `:` — command line (`:grades`, `:week 1`, `:reload`, `:logout`, …) +- `?` — help +- `/` — filter grades (grades view) +- `[` / `]` — previous / next week (timetable view) +- `q` — log out +- `Esc` — close modal / leave command line + +## How it works + +``` +browser ── fetch /api/* ──> aiohttp app ── HTTPS ──> synergia.librus.pl + │ │ + │ session cookie only ├─ librus.py (OAuth + gateway API + message scraping) + └──────────────────────────┘ +``` + +1. `POST /api/login` performs the same flow as librusik: Synergia portal + session, `api.librus.pl/OAuth/Authorization?client_id=46`, the 2FA redirect, + then `Auth/TokenInfo` / `Auth/UserInfo` to verify the resulting cookies. +2. Cookies (`DZIENNIKSID`, `SDZIENNIKSID`, …) stay in an in-memory `Librus` + object on the server. The browser gets a random `librustui_session` cookie. +3. `/api/*` endpoints wrap the client methods from librusik and return JSON. + Message HTML is sanitised with BeautifulSoup before it reaches the browser. +4. Sessions expire after 12 h idle / 24 h absolute; restarting the server + invalidates all of them. + +### Files + +``` +app.py aiohttp server, sessions, API endpoints, sanitizer +librus.py async Synergia client (adapted from librusik, MIT) +demo.py offline dataset behind --demo +static/index.html TUI shell +static/style.css phosphor terminal theme +static/js/00-core.js state, helpers, API wrapper, chrome +static/js/10-auth.js login, session chrome, clock +static/js/20-dashboard.js +static/js/22-grades.js +static/js/24-timetable.js +static/js/26-more-views.js +static/js/30-app.js navigation, commands, keyboard, bootstrap +``` + +## Security notes + +- Bind address defaults to `127.0.0.1`, not `0.0.0.0`. +- Credentials are sent from the browser to this server, then to Librus; they + are **not** logged or written to disk. +- Session cookies are `HttpOnly`, `SameSite=Lax`. Responses set a strict CSP, + `X-Frame-Options: DENY`, `nosniff` and `no-referrer`. +- Cross-origin `POST`s are rejected by an `Origin` check. +- Message content is treated as untrusted and sanitised server-side (scripts, + event handlers, iframes and unsafe URL schemes are removed). +- If you expose this on the internet, put it behind HTTPS (e.g. Caddy/nginx), + set `--host 127.0.0.1`, and consider adding authentication in front of it. + +## Limitations + +- Librus has no public API contract. If Synergia changes its markup or gateway + responses, parts can break; the UI shows a "signal lost" panel with the + upstream error instead of crashing. +- The messages reader scrapes the HTML portal, exactly like librusik, so it is + the most fragile view. +- Attachments are proxied through the server and require the original session + cookies to still be valid. +- One process keeps sessions in memory; there is no multi-user persistence. + Restart = everyone logs in again. + +## Credits + +Client flow, endpoints and scraping logic are adapted from +[dani3l0/librusik](https://github.com/dani3l0/librusik), MIT licensed, +copyright © 2022 dani3l0. + +This project is unofficial and not affiliated with Librus. Use it with your own +account at your own risk. diff --git a/app.py b/app.py new file mode 100644 index 0000000..4de248c --- /dev/null +++ b/app.py @@ -0,0 +1,631 @@ +"""librus-tui - a terminal-looking web frontend for Librus Synergia. + +Run: + python app.py # real mode, http://127.0.0.1:8765 + python app.py --demo # offline demo data + +The Synergia client is adapted from dani3l0/librusik (stable branch). +""" + +import argparse +import asyncio +import json +import logging +import os +import re +import secrets +import time +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + +from aiohttp import web +from bs4 import BeautifulSoup, Comment + +from demo import DemoLibrus +from librus import Librus + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" +COOKIE_NAME = "librustui_session" +SESSION_IDLE = 12 * 60 * 60 +SESSION_MAX = 24 * 60 * 60 +VERSION = "1.0.0" + +log = logging.getLogger("librustui") + + +# --------------------------------------------------------------------------- errors + + +class ApiError(Exception): + def __init__(self, status, message): + super().__init__(message) + self.status = status + self.message = message + + +# --------------------------------------------------------------------------- sessions + + +@dataclass +class Session: + token: str + client: object + login: str + demo: bool = False + created: float = field(default_factory=time.time) + last_seen: float = field(default_factory=time.time) + + def touch(self): + self.last_seen = time.time() + + def expired(self): + now = time.time() + return ( + now - self.last_seen > SESSION_IDLE + or now - self.created > SESSION_MAX + ) + + +SESSIONS: dict[str, Session] = {} +LOGIN_ATTEMPTS: dict[str, list[float]] = {} + + +def cleanup_sessions(): + for token in [t for t, s in SESSIONS.items() if s.expired()]: + SESSIONS.pop(token, None) + + +# --------------------------------------------------------------------------- html sanitizer + +ALLOWED_TAGS = { + "p", "br", "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", + "ul", "ol", "li", "a", "img", "blockquote", "pre", "code", "hr", + "h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "font", + "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", + "center", "small", "big", "sub", "sup", "dl", "dt", "dd", +} +ALLOWED_ATTRS = { + "a": {"href", "title"}, + "img": {"src", "alt", "title", "width", "height"}, + "td": {"colspan", "rowspan"}, + "th": {"colspan", "rowspan"}, + "font": {"color", "size"}, +} +SAFE_URL = re.compile(r"^(https?://|mailto:|#|/)") + + +def sanitize_html(html): + """Allow a small set of formatting tags; drop scripts and handlers.""" + if not html: + return "" + soup = BeautifulSoup(html, "html.parser") + for comment in soup.find_all(string=lambda text: isinstance(text, Comment)): + comment.extract() + for tag in soup.find_all(True): + if tag.name not in ALLOWED_TAGS: + tag.unwrap() + continue + allowed = ALLOWED_ATTRS.get(tag.name, set()) + attrs = {} + for key, value in list(tag.attrs.items()): + key_lower = key.lower() + if key_lower not in allowed or key_lower.startswith("on"): + continue + if key_lower in ("href", "src"): + value = str(value).strip() + if not SAFE_URL.match(value): + continue + if value.startswith("/"): + value = "https://synergia.librus.pl" + value + attrs[key] = value + tag.attrs = attrs + if tag.name == "a" and attrs.get("href"): + tag["target"] = "_blank" + tag["rel"] = "noopener noreferrer" + return str(soup) + + +# --------------------------------------------------------------------------- helpers + + +def safe_json(data): + return json.dumps(data, ensure_ascii=False, default=str) + + +def json_response(data, status=200): + return web.Response( + text=safe_json(data), + status=status, + content_type="application/json", + ) + + +def err(status, message): + return json_response({"error": message}, status=status) + + +def get_session(request): + session = request.get("session") + if session is None: + raise ApiError(401, "Not authenticated.") + return session + + +def validate_message_link(link): + """Allow Unicode attachment paths, but no absolute paths or traversal.""" + if not link or len(link) > 500: + return False + if link.startswith(("/", "\\")): + return False + if any(ord(char) < 32 for char in link): + return False + if any(char in link for char in ('"', "'", "<", ">", "\\")): + return False + if any(part in (".", "..") for part in link.split("/")): + return False + return True + + +async def safe_call(coro, default=None): + """Await a client call, returning default instead of exploding.""" + try: + return await coro + except Exception as exc: # upstream markup/JSON changes are not fatal + log.warning("upstream call failed: %s", exc) + return default + + +# --------------------------------------------------------------------------- API + + +async def api_config(request): + return json_response( + { + "demo": bool(request.app["demo"]), + "version": VERSION, + } + ) + + +async def api_session(request): + session = request.get("session") + return json_response( + { + "authenticated": session is not None, + "login": session.login if session else None, + "demo": session.demo if session else False, + "demoAvailable": bool(request.app["demo"]), + } + ) + + +async def api_login(request): + body = await request.json() + if not isinstance(body, dict): + raise ApiError(400, "Expected a JSON object.") + + cleanup_sessions() + + remote = request.remote or "unknown" + now = time.time() + attempts = [t for t in LOGIN_ATTEMPTS.get(remote, []) if now - t < 60] + if len(attempts) >= 6: + raise ApiError(429, "Too many login attempts. Wait a minute.") + attempts.append(now) + LOGIN_ATTEMPTS[remote] = attempts + + demo_requested = bool(body.get("demo")) + if request.app["demo"] and demo_requested: + client = DemoLibrus() + token = secrets.token_urlsafe(32) + SESSIONS[token] = Session(token=token, client=client, login="demo", demo=True) + response = json_response({"ok": True, "demo": True}) + response.set_cookie( + COOKIE_NAME, + token, + max_age=SESSION_MAX, + httponly=True, + samesite="Lax", + path="/", + ) + return response + + # A fresh login always replaces the previous server-side session. + old_token = request.cookies.get(COOKIE_NAME) + if old_token: + SESSIONS.pop(old_token, None) + + login = body.get("login") + password = body.get("password") + if not isinstance(login, str) or not isinstance(password, str): + raise ApiError(400, "Login and password are required.") + login = login.strip() + if not login or not password: + raise ApiError(400, "Login and password are required.") + if len(login) > 200 or len(password) > 500: + raise ApiError(400, "Credentials are too long.") + + client = Librus() + try: + ok = await asyncio.wait_for(client.mktoken(login, password), timeout=55) + except asyncio.TimeoutError: + raise ApiError(504, "Synergia did not answer in time.") + + if not ok: + if client.credentials_rejected: + raise ApiError(401, "Librus rejected these credentials.") + raise ApiError( + 502, + "Could not reach Synergia (outage, captcha or network error). " + "Try again in a moment.", + ) + + token = secrets.token_urlsafe(32) + SESSIONS[token] = Session(token=token, client=client, login=login) + response = json_response({"ok": True, "demo": False}) + response.set_cookie( + COOKIE_NAME, + token, + max_age=SESSION_MAX, + httponly=True, + samesite="Lax", + path="/", + ) + log.info("login ok for %s", login) + return response + + +async def api_logout(request): + session = request.get("session") + if session: + SESSIONS.pop(session.token, None) + response = json_response({"ok": True}) + response.del_cookie(COOKIE_NAME, path="/") + return response + + +async def api_bootstrap(request): + session = get_session(request) + client = session.client + + me, school, lucky, behaviour = await asyncio.gather( + safe_call(client.get_me()), + safe_call(client.get_school()), + safe_call(client.get_lucky_number()), + safe_call(client.get_behaviour_grade()), + ) + if me is None: + SESSIONS.pop(session.token, None) + raise ApiError( + 502, + "Synergia did not return account data. The session may have expired.", + ) + return json_response( + { + "me": me, + "school": school, + "luckyNumber": lucky, + "behaviour": behaviour, + } + ) + + +async def api_notifications(request): + session = get_session(request) + data = await safe_call(session.client.get_notifications()) + if data is None: + return json_response( + {"grades": 0, "exams": 0, "absences": 0, "conferences": 0, + "unavailable": True} + ) + return json_response(data) + + +async def api_grades(request): + session = get_session(request) + data = await safe_call(session.client.get_grades()) + if data is None: + raise ApiError(502, "Could not load grades from Synergia.") + return json_response({"subjects": data}) + + +async def api_timetable(request): + session = get_session(request) + try: + week = int(request.query.get("week", "0")) + except ValueError: + week = 0 + if not 0 <= week <= 3: + raise ApiError(400, "week must be between 0 and 3.") + data = await safe_call(session.client.get_timetable(week)) + if data is None: + raise ApiError(502, "Could not load the timetable from Synergia.") + return json_response(data) + + +async def api_exams(request): + session = get_session(request) + data = await safe_call(session.client.get_exams()) + if data is None: + raise ApiError(502, "Could not load homework from Synergia.") + return json_response({"exams": data}) + + +async def api_attendances(request): + session = get_session(request) + data = await safe_call(session.client.get_attendances()) + if data is None: + raise ApiError(502, "Could not load attendance from Synergia.") + return json_response({"attendances": data}) + + +async def api_messages(request): + session = get_session(request) + data = await safe_call(session.client.get_messages(), default=[]) + return json_response({"messages": data, "demo": session.demo}) + + +async def api_message(request): + session = get_session(request) + link = request.query.get("link", "") + if not validate_message_link(link): + raise ApiError(400, "Invalid message link.") + data = await safe_call(session.client.get_message(link)) + if data is None: + raise ApiError(502, "Could not load the message from Synergia.") + data = dict(data) + data["content"] = sanitize_html(data.get("content", "")) + return json_response(data) + + +async def api_download(request): + session = get_session(request) + link = request.query.get("link", "") + if not validate_message_link(link): + raise ApiError(400, "Invalid attachment link.") + result = await safe_call(session.client.download_file(link)) + if not result: + raise ApiError(404, "Attachment not found or already expired.") + + headers = result.get("headers") or {} + content_type = headers.get("Content-Type", "application/octet-stream") + disposition = headers.get("Content-Disposition") or "" + # Always force a download, even if Librus forgot the attachment header. + if not disposition.lower().startswith("attachment"): + name = link.rsplit("/", 1)[-1] or "attachment" + name = re.sub(r"[^A-Za-z0-9._-]", "_", name) + disposition = 'attachment; filename="%s"' % name + + response = web.Response(body=result.get("content", b"")) + response.headers["Content-Type"] = content_type + response.headers["Content-Disposition"] = disposition + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Content-Security-Policy"] = "sandbox" + return response + + +async def api_freedays(request): + session = get_session(request) + school, teachers = await asyncio.gather( + safe_call(session.client.get_free_days(), default=[]), + safe_call(session.client.get_teacher_free_days(), default=[]), + ) + return json_response({"school": school or [], "teachers": teachers or []}) + + +async def api_conferences(request): + session = get_session(request) + data = await safe_call(session.client.get_parent_teacher_conferences(), default=[]) + return json_response({"conferences": data or []}) + + +# --------------------------------------------------------------------------- static + + +async def index(request): + response = web.FileResponse(STATIC_DIR / "index.html") + response.headers["Cache-Control"] = "no-store" + return response + + +async def favicon(request): + return web.FileResponse(STATIC_DIR / "favicon.svg") + + +# --------------------------------------------------------------------------- middleware + + +@web.middleware +async def security_middleware(request, handler): + # Reject cross-site form posts. SameSite=Lax already covers most of it. + if request.method in ("POST", "PUT", "DELETE", "PATCH") and request.path.startswith("/api/"): + origin = request.headers.get("Origin") + if origin: + if urlparse(origin).netloc != request.host: + return err(403, "Cross-origin request rejected.") + + response = await handler(request) + if request.path == "/" or request.path.startswith("/api/"): + response.headers.setdefault("Cache-Control", "no-store") + response.headers.setdefault("Pragma", "no-cache") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https://synergia.librus.pl; " + "connect-src 'self'; " + "font-src 'self'; " + "object-src 'none'; " + "base-uri 'none'; " + "form-action 'self'; " + "frame-ancestors 'none'", + ) + return response + + +@web.middleware +async def auth_middleware(request, handler): + public = { + "/api/config", + "/api/session", + "/api/login", + "/healthz", + } + session = None + token = request.cookies.get(COOKIE_NAME) + if token: + session = SESSIONS.get(token) + if session and session.expired(): + SESSIONS.pop(token, None) + session = None + elif session: + session.touch() + request["session"] = session + + if request.path.startswith("/api/") and request.path not in public and session is None: + return err(401, "Not authenticated.") + return await handler(request) + + +@web.middleware +async def error_middleware(request, handler): + try: + return await handler(request) + except ApiError as exc: + return err(exc.status, exc.message) + except web.HTTPException: + raise + except json.JSONDecodeError: + return err(400, "Malformed JSON body.") + except Exception: + log.exception("unhandled error on %s %s", request.method, request.path) + return err(500, "Internal server error.") + + +# --------------------------------------------------------------------------- app + + +async def healthz(request): + return json_response( + {"ok": True, "sessions": len(SESSIONS), "version": VERSION} + ) + + +async def cleanup_loop(app): + try: + while True: + await asyncio.sleep(600) + cleanup_sessions() + cutoff = time.time() - 120 + for ip in list(LOGIN_ATTEMPTS): + LOGIN_ATTEMPTS[ip] = [ + t for t in LOGIN_ATTEMPTS[ip] if t > cutoff + ] + if not LOGIN_ATTEMPTS[ip]: + LOGIN_ATTEMPTS.pop(ip, None) + except asyncio.CancelledError: + pass + + +def create_app(demo=False): + app = web.Application(middlewares=[ + error_middleware, + security_middleware, + auth_middleware, + ]) + app["demo"] = demo + app["started"] = time.time() + + app.router.add_get("/api/config", api_config) + app.router.add_get("/api/session", api_session) + app.router.add_post("/api/login", api_login) + app.router.add_post("/api/logout", api_logout) + app.router.add_get("/api/bootstrap", api_bootstrap) + app.router.add_get("/api/notifications", api_notifications) + app.router.add_get("/api/grades", api_grades) + app.router.add_get("/api/timetable", api_timetable) + app.router.add_get("/api/exams", api_exams) + app.router.add_get("/api/attendances", api_attendances) + app.router.add_get("/api/messages", api_messages) + app.router.add_get("/api/message", api_message) + app.router.add_get("/api/download", api_download) + app.router.add_get("/api/freedays", api_freedays) + app.router.add_get("/api/conferences", api_conferences) + app.router.add_get("/healthz", healthz) + + app.router.add_get("/", index) + app.router.add_get("/favicon.ico", favicon) + app.router.add_get("/favicon.svg", favicon) + app.router.add_static("/static", STATIC_DIR, show_index=False) + + async def start_tasks(app): + app["cleanup_task"] = asyncio.create_task(cleanup_loop(app)) + + async def stop_tasks(app): + task = app.get("cleanup_task") + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + app.on_startup.append(start_tasks) + app.on_cleanup.append(stop_tasks) + return app + + +BANNER = r""" + _ _ _ _ _____ _ _ ___ + | | (_) |__ _ _ _| |_ _ _ |_ _| | | |_ _| + | |__ | | '_ \ '_| _| || | | | | |_| || | + |____||_|_.__/_| \__|\_,_| |_| \___/|___| +""" + + +def main(): + parser = argparse.ArgumentParser( + description="TUI-styled web frontend for Librus Synergia" + ) + parser.add_argument("--host", default=os.environ.get("LIBRUS_HOST", "127.0.0.1")) + parser.add_argument( + "--port", type=int, default=int(os.environ.get("LIBRUS_PORT", "8765")) + ) + parser.add_argument( + "--demo", + action="store_true", + default=os.environ.get("LIBRUS_DEMO") == "1", + help="serve offline sample data instead of contacting Librus", + ) + parser.add_argument("--debug", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + ) + + print(BANNER) + if args.demo: + print(" DEMO MODE - no Librus account is contacted.\n") + print(" http://%s:%d\n" % (args.host, args.port)) + print(" Ctrl+C to stop\n") + + app = create_app(demo=args.demo) + web.run_app( + app, + host=args.host, + port=args.port, + print=None, + access_log=log if args.debug else None, + ) + + +if __name__ == "__main__": + main() diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..81942c3 --- /dev/null +++ b/demo.py @@ -0,0 +1,563 @@ +"""Offline demo data for librus-tui. + +Start the server with --demo to explore the interface without a real +Librus account. All dates are generated relative to today so the dashboard +and timetable always look alive. +""" + +import asyncio +import copy +from datetime import date, datetime, timedelta + +WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +SUBJECTS = [ + "Język polski", + "Matematyka", + "Język angielski", + "Fizyka", + "Chemia", + "Biologia", + "Historia", + "Informatyka", + "Geografia", + "Wychowanie fizyczne", +] + +TEACHERS = { + "polski": {"FirstName": "Maria", "LastName": "Wiśniewska"}, + "matma": {"FirstName": "Piotr", "LastName": "Zieliński"}, + "angielski": {"FirstName": "Sarah", "LastName": "Kowalczyk"}, + "fizyka": {"FirstName": "Andrzej", "LastName": "Lewandowski"}, + "chemia": {"FirstName": "Ewa", "LastName": "Szymańska"}, + "biologia": {"FirstName": "Katarzyna", "LastName": "Wójcik"}, + "historia": {"FirstName": "Tomasz", "LastName": "Kamiński"}, + "informatyka": {"FirstName": "Michał", "LastName": "Nowicki"}, + "geografia": {"FirstName": "Agnieszka", "LastName": "Dąbrowska"}, + "wf": {"FirstName": "Marek", "LastName": "Jankowski"}, +} + + +def iso(d): + return d.strftime("%Y-%m-%d") + + +def grade(value, weight, category, teacher, days_ago=3, comment="No comment", **extra): + base = { + "Grade": value, + "Weight": weight, + "Comment": comment, + "Category": category, + "isFinal": False, + "isSemester": False, + "separator": False, + "underlined": False, + "Semester": 1, + "AddDate": iso(date.today() - timedelta(days=days_ago)), + "AddedBy": copy.deepcopy(teacher), + } + base.update(extra) + return base + + +def build_grades(): + t = TEACHERS + return { + "Język polski": [ + grade("5", "2", "Sprawdzian", t["polski"], 12, "Świetna analiza lektury."), + grade("4", "1", "Kartkówka", t["polski"], 9), + grade("5", "1", "Aktywność", t["polski"], 2), + grade("4", "2", "Wypracowanie", t["polski"], 21, "Bardzo dobry styl, drobne błędy."), + ], + "Matematyka": [ + grade("5", "3", "Sprawdzian", t["matma"], 7, "Bezbłędnie!"), + grade("4", "1", "Kartkówka", t["matma"], 3), + grade("3", "2", "Praca domowa", t["matma"], 14), + grade("5", "2", "Sprawdzian", t["matma"], 28), + grade("4", "1", "Aktywność", t["matma"], 1), + ], + "Język angielski": [ + grade("5", "2", "Test", t["angielski"], 5), + grade("5", "1", "Speaking", t["angielski"], 10), + grade("4", "2", "Essay", t["angielski"], 19), + ], + "Fizyka": [ + grade("4", "2", "Sprawdzian", t["fizyka"], 8), + grade("3", "1", "Kartkówka", t["fizyka"], 16), + grade("4", "1", "Doświadczenie", t["fizyka"], 4), + ], + "Chemia": [ + grade("4", "2", "Sprawdzian", t["chemia"], 11), + grade("5", "1", "Kartkówka", t["chemia"], 6), + ], + "Biologia": [ + grade("5", "2", "Sprawdzian", t["biologia"], 13), + grade("5", "1", "Projekt", t["biologia"], 20), + ], + "Historia": [ + grade("4", "2", "Sprawdzian", t["historia"], 9), + grade("4", "1", "Kartkówka", t["historia"], 2), + grade("5", "1", "Wypowiedź", t["historia"], 25), + ], + "Informatyka": [ + grade("6", "2", "Projekt", t["informatyka"], 6, "Wyjątkowo dobrze zaprojektowana aplikacja."), + grade("5", "1", "Kartkówka", t["informatyka"], 15), + ], + "Geografia": [ + grade("4", "2", "Sprawdzian", t["geografia"], 10), + grade("4", "1", "Mapa", t["geografia"], 5), + ], + "Wychowanie fizyczne": [ + grade("5", "1", "Aktywność", t["wf"], 3), + grade("5", "1", "Test sprawności", t["wf"], 17), + ], + } + + +def lesson(no, subject, teacher, room, start, end, **extra): + item = { + "Lesson": no, + "Subject": subject, + "isSubstitution": False, + "isCancelled": False, + "Teacher": copy.deepcopy(teacher), + "HourFrom": start, + "HourTo": end, + "Classroom": room, + } + item.update(extra) + return item + + +PLAN = { + "Monday": [ + (1, "Język polski", "polski", "A12", "08:00", "08:45"), + (2, "Matematyka", "matma", "B04", "08:55", "09:40"), + (3, "Język angielski", "angielski", "A07", "09:50", "10:35"), + (4, "Historia", "historia", "A21", "10:45", "11:30"), + (6, "Wychowanie fizyczne", "wf", "SALA", "12:45", "13:30"), + ], + "Tuesday": [ + (1, "Fizyka", "fizyka", "C02", "08:00", "08:45"), + (2, "Chemia", "chemia", "C03", "08:55", "09:40"), + (3, "Matematyka", "matma", "B04", "09:50", "10:35"), + (5, "Język polski", "polski", "A12", "11:45", "12:30"), + (7, "Informatyka", "informatyka", "INF", "13:45", "14:30"), + ], + "Wednesday": [ + (1, "Biologia", "biologia", "B11", "08:00", "08:45"), + (2, "Geografia", "geografia", "A19", "08:55", "09:40"), + (3, "Język angielski", "angielski", "A07", "09:50", "10:35"), + (4, "Matematyka", "matma", "B04", "10:45", "11:30"), + (6, "Historia", "historia", "A21", "12:45", "13:30"), + ], + "Thursday": [ + (1, "Matematyka", "matma", "B04", "08:00", "08:45"), + (2, "Fizyka", "fizyka", "C02", "08:55", "09:40"), + (3, "Język polski", "polski", "A12", "09:50", "10:35"), + (5, "Wychowanie fizyczne", "wf", "SALA", "11:45", "12:30"), + ], + "Friday": [ + (1, "Chemia", "chemia", "C03", "08:00", "08:45"), + (2, "Biologia", "biologia", "B11", "08:55", "09:40"), + (3, "Informatyka", "informatyka", "INF", "09:50", "10:35"), + (4, "Język angielski", "angielski", "A07", "10:45", "11:30"), + ], +} + +SUBSTITUTIONS = { + "Wednesday": {4: {"isSubstitution": True, "Classroom": "A02"}}, + "Friday": {3: {"isCancelled": True, "isSubstitution": True}}, +} + + +def build_timetable(week=0): + today = date.today() + week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=week) + week_end = week_start + timedelta(days=6) + data = {} + + for index, day in enumerate(WEEKDAYS[:5]): + entries = [] + for no, subject, teacher_key, room, start, end in PLAN[day]: + extra = SUBSTITUTIONS.get(day, {}).get(no, {}) if week == 0 else {} + item = lesson( + no, + subject, + TEACHERS[teacher_key], + extra.get("Classroom", room), + start, + end, + **{k: v for k, v in extra.items() if k != "Classroom"}, + ) + item["Date"] = iso(week_start + timedelta(days=index)) + entries.append(item) + if week == 1 and day == "Thursday": + entries.append( + { + "Lesson": "-", + "Subject": "Konsultacje z matematyki", + "isSubstitution": False, + "isCancelled": False, + "Teacher": copy.deepcopy(TEACHERS["matma"]), + "HourFrom": "15:00", + "HourTo": "16:00", + "Classroom": "B04", + "Date": iso(week_start + timedelta(days=3)), + "isAdditional": True, + } + ) + if entries: + data[day] = entries + + return { + "Timetable": data, + "weekStart": iso(week_start), + "weekEnd": iso(week_end), + "week": week, + } + + +def build_exams(): + today = date.today() + return [ + { + "Lesson": "Matematyka", + "AddedBy": copy.deepcopy(TEACHERS["matma"]), + "Type": "Sprawdzian", + "StartTime": "09:50", + "EndTime": "10:35", + "Date": iso(today + timedelta(days=2)), + "AddDate": iso(today - timedelta(days=4)) + " 14:12:00", + "Content": "Funkcja kwadratowa: postać ogólna, kanoniczna i iloczynowa. Zadania 1-8, str. 142.", + }, + { + "Lesson": "Język polski", + "AddedBy": copy.deepcopy(TEACHERS["polski"]), + "Type": "Wypracowanie", + "StartTime": "08:00", + "EndTime": "08:45", + "Date": iso(today + timedelta(days=4)), + "AddDate": iso(today - timedelta(days=2)) + " 10:03:00", + "Content": "Rozprawka: „Czy warto znać przeszłość?”. Minimum 300 słów, odwołanie do lektury.", + }, + { + "Lesson": "Fizyka", + "AddedBy": copy.deepcopy(TEACHERS["fizyka"]), + "Type": "Kartkówka", + "StartTime": "08:55", + "EndTime": "09:40", + "Date": iso(today + timedelta(days=7)), + "AddDate": iso(today - timedelta(days=1)) + " 16:44:00", + "Content": "Zasady dynamiki Newtona. Wzory i jednostki.", + }, + { + "Lesson": "Informatyka", + "AddedBy": copy.deepcopy(TEACHERS["informatyka"]), + "Type": "Projekt", + "StartTime": "", + "EndTime": "", + "Date": iso(today + timedelta(days=10)), + "AddDate": iso(today - timedelta(days=6)) + " 09:15:00", + "Content": "Aplikacja terminalowa w Pythonie: minimum 3 podkomendy, obsługa błędów i README.", + }, + ] + + +def build_attendances(): + today = date.today() + entries = [ + ("Matematyka", "Obecność", "ob.", True, 0), + ("Język polski", "Obecność", "ob.", True, 0), + ("Fizyka", "Spóźnienie", "sp.", False, 1), + ("Chemia", "Nieobecność", "nb.", False, 2), + ("Historia", "Obecność", "ob.", True, 2), + ("Język angielski", "Zwolnienie", "zw.", True, 3), + ("Biologia", "Nieobecność", "nb.", False, 4), + ("Informatyka", "Obecność", "ob.", True, 5), + ("Geografia", "Obecność", "ob.", True, 6), + ("Wychowanie fizyczne", "Obecność", "ob.", True, 7), + ("Matematyka", "Nieobecność", "nb.", False, 8), + ("Język polski", "Obecność", "ob.", True, 9), + ] + result = [] + for subject, kind, short, presence, days_ago in entries: + teacher_key = { + "Matematyka": "matma", + "Język polski": "polski", + "Fizyka": "fizyka", + "Chemia": "chemia", + "Historia": "historia", + "Język angielski": "angielski", + "Biologia": "biologia", + "Informatyka": "informatyka", + "Geografia": "geografia", + "Wychowanie fizyczne": "wf", + }[subject] + d = today - timedelta(days=days_ago) + result.append( + { + "Lesson": subject, + "Type": kind, + "Short": short, + "isPresence": presence, + "Added": iso(d) + " 18:00:00", + "Date": iso(d), + "AddedBy": copy.deepcopy(TEACHERS[teacher_key]), + } + ) + return result + + +MESSAGES = [ + { + "from": "Maria Wiśniewska", + "subject": "Wyniki próbnej matury z języka polskiego", + "date": "07.11.2026 14:22", + "link": "1081-1", + }, + { + "from": "Piotr Zieliński", + "subject": "Konsultacje przed sprawdzianem", + "date": "06.11.2026 09:10", + "link": "1074-1", + }, + { + "from": "Sekretariat", + "subject": "Zebranie z rodzicami – 12 listopada", + "date": "05.11.2026 16:45", + "link": "1069-1", + }, + { + "from": "Michał Nowicki", + "subject": "Dodatkowe zajęcia z algorytmiki", + "date": "04.11.2026 11:03", + "link": "1062-1", + }, +] + +MESSAGE_BODIES = { + "1081-1": { + "subject": "Wyniki próbnej matury z języka polskiego", + "from": "Maria Wiśniewska (nauczyciel)", + "date": "07.11.2026 14:22", + "read": "Tak", + "content": ( + "

Dzień dobry,

" + "

wyniki próbnej matury z języka polskiego są już dostępne w dzienniku. " + "Średnia klasy to 78%. Gratulacje dla wszystkich, którzy przekroczyli 80%.

" + "

Omówienie arkusza odbędzie się w poniedziałek na lekcji 3. Proszę przynieść " + "zeszyty i arkusze.

" + "

Pozdrawiam,
Maria Wiśniewska

" + ), + "attachments": [ + {"name": "klucz_odpowiedzi.pdf", "nice": "1081-1/klucz.pdf"}, + ], + }, + "1074-1": { + "subject": "Konsultacje przed sprawdzianem", + "from": "Piotr Zieliński (nauczyciel)", + "date": "06.11.2026 09:10", + "read": "Tak", + "content": ( + "

Przypominam, że w czwartek o 15:00 w sali B04 odbędą się konsultacje " + "przed sprawdzianem z funkcji kwadratowej.

" + "

Przygotujcie pytania do zadań z rozdziału 5.

" + ), + "attachments": [], + }, + "1069-1": { + "subject": "Zebranie z rodzicami – 12 listopada", + "from": "Sekretariat (administracja)", + "date": "05.11.2026 16:45", + "read": "Nie", + "content": ( + "

Uprzejmie informujemy, że zebranie z rodzicami odbędzie się " + "12 listopada 2026 r. o godz. 17:00 w sali gimnastycznej.

" + "

Po spotkaniu ogólnym zapraszamy na spotkania z wychowawcami.

" + ), + "attachments": [ + {"name": "harmonogram_zebran.pdf", "nice": "1069-1/harmonogram.pdf"}, + ], + }, + "1062-1": { + "subject": "Dodatkowe zajęcia z algorytmiki", + "from": "Michał Nowicki (nauczyciel)", + "date": "04.11.2026 11:03", + "read": "Tak", + "content": ( + "

Zapraszam na dodatkowe zajęcia z algorytmiki w każdą środę o 15:30 " + "w sali INF. Pracujemy nad projektami do olimpiady informatycznej.

" + ), + "attachments": [], + }, +} + + +def build_free_days(): + today = date.today() + return [ + { + "Name": "Wigilia szkolna", + "DateFrom": iso(date(today.year, 12, 23)), + "DateTo": iso(date(today.year, 12, 31)), + }, + { + "Name": "Dzień Edukacji Narodowej", + "DateFrom": iso(date(today.year, 10, 14)), + "DateTo": iso(date(today.year, 10, 14)), + }, + { + "Name": "Ferie zimowe", + "DateFrom": iso(date(today.year + 1, 1, 26)), + "DateTo": iso(date(today.year + 1, 2, 8)), + }, + { + "Name": "Wielkanoc", + "DateFrom": iso(date(today.year + 1, 3, 28)), + "DateTo": iso(date(today.year + 1, 4, 2)), + }, + ] + + +def build_teacher_free_days(): + today = date.today() + return [ + { + "Teacher": copy.deepcopy(TEACHERS["matma"]), + "DateFrom": iso(today + timedelta(days=5)), + "DateTo": iso(today + timedelta(days=5)), + "TimeFrom": "10:00:00", + "TimeTo": "14:00:00", + }, + { + "Teacher": copy.deepcopy(TEACHERS["angielski"]), + "DateFrom": iso(today + timedelta(days=9)), + "DateTo": iso(today + timedelta(days=11)), + }, + ] + + +def build_conferences(): + today = date.today() + return [ + { + "Name": "Zebranie śródroczne", + "Topic": "Wyniki w nauce i frekwencja", + "Room": "A12", + "Date": iso(today + timedelta(days=4)), + "Time": "17:00:00", + }, + { + "Name": "Konsultacje indywidualne", + "Topic": "Matematyka – przygotowanie do matury", + "Room": "B04", + "Date": iso(today + timedelta(days=11)), + "Time": "16:30:00", + }, + ] + + +class DemoLibrus: + """Drop-in replacement for librus.Librus backed by sample data.""" + + demo = True + credentials_rejected = False + headers = {} + + def __init__(self): + self._grades = build_grades() + self._attendances = build_attendances() + + async def mktoken(self, login, password): + await asyncio.sleep(0.35) + return True + + async def activate_api_access(self): + return True + + async def get_me(self): + return { + "FirstName": "Alex", + "LastName": "Nowak", + "Tutor": copy.deepcopy(TEACHERS["polski"]), + "SchoolYearStarts": "2026-09-01", + "SchoolYearMiddles": "2027-01-31", + "SchoolYearEnds": "2027-06-25", + "Type": "Uczeń", + "Class": "3 A", + } + + async def get_school(self): + return { + "Name": "I Liceum Ogólnokształcące im. Mikołaja Kopernika", + "Street": "ul. Szkolna", + "BuildingNumber": "12", + "PostCode": "00-001", + "Town": "Warszawa", + } + + async def get_lucky_number(self): + return 7 + + async def get_behaviour_grade(self): + return { + "Grade": "bardzo dobre", + "Teacher": copy.deepcopy(TEACHERS["polski"]), + } + + async def get_grades(self): + return copy.deepcopy(self._grades) + + async def get_timetable(self, week=0): + return build_timetable(week) + + async def get_exams(self): + return build_exams() + + async def get_attendances(self): + return copy.deepcopy(self._attendances) + + async def get_notifications(self, days=14): + return { + "grades": sum(len(v) for v in self._grades.values()), + "exams": len(build_exams()), + "absences": sum(1 for a in self._attendances if not a["isPresence"]), + "conferences": len(build_conferences()), + } + + async def get_messages(self): + return copy.deepcopy(MESSAGES) + + async def get_message(self, link): + body = copy.deepcopy( + MESSAGE_BODIES.get(link, MESSAGE_BODIES["1081-1"]) + ) + body["attachments"] = [ + dict( + attachment, + source="https://synergia.librus.pl/wiadomosci/pobierz_zalacznik/" + + attachment["nice"], + direct="/get", + ) + for attachment in body["attachments"] + ] + return body + + async def download_file(self, link): + return { + "headers": { + "Content-Type": "application/pdf", + "Content-Disposition": 'attachment; filename="demo.pdf"', + }, + "content": b"%PDF-1.4\n% librus-tui demo attachment\n", + } + + async def get_free_days(self): + return build_free_days() + + async def get_teacher_free_days(self): + return build_teacher_free_days() + + async def get_parent_teacher_conferences(self): + return build_conferences() diff --git a/librus.py b/librus.py new file mode 100644 index 0000000..e3dac14 --- /dev/null +++ b/librus.py @@ -0,0 +1,722 @@ +"""Async client for the Librus Synergia portal. + +Adapted from dani3l0/librusik (lib/api/librus.py, stable branch): +https://github.com/dani3l0/librusik + +The OAuth flow, API host and scraping logic follow that implementation. +Changes here are limited to defensive parsing, week selection for the +timetable and a few helpers the web frontend needs. +""" + +import asyncio +import json +import re +import time +from datetime import datetime, timedelta +from http.cookies import SimpleCookie + +import aiohttp +import yarl +from bs4 import BeautifulSoup + +BROWSER_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) " + "Gecko/20100101 Firefox/130.0" + ) +} +SYNERGIA_URL = yarl.URL("https://synergia.librus.pl/") +OAUTH_CLIENT_ID = 46 + +DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + +class LibrusError(Exception): + """Raised when Synergia returns an unusable response.""" + + +class Librus: + def __init__(self, session=None, request_timeout=8, retries=3): + self.host = "https://synergia.librus.pl/gateway/api/2.0/" + self.msg_host = "https://synergia.librus.pl" + self.headers = session + self.request_timeout = request_timeout + self.retries = retries + # Set by mktoken(): True only when Librus itself rejected the + # credentials, as opposed to a network error or an outage page. + self.credentials_rejected = False + + # ------------------------------------------------------------------ auth + + def client(self): + # The cookie jar is pinned to HTTPS librus.pl so session cookies are + # never leaked to another host through a redirect. + session = aiohttp.ClientSession( + headers=BROWSER_HEADERS, + timeout=aiohttp.ClientTimeout(total=self.request_timeout), + ) + if self.headers: + cookies = SimpleCookie() + for name, value in self.headers.items(): + cookies[name] = value + cookies[name]["domain"] = "librus.pl" + cookies[name]["secure"] = True + session.cookie_jar.update_cookies(cookies, SYNERGIA_URL) + return session + + async def activate_api_access(self): + try: + async with self.client() as session: + resp = await session.get(self.host + "Auth/TokenInfo") + if resp.status != 200: + return False + identifier = (await resp.json())["UserIdentifier"] + resp2 = await session.get( + self.host + "Auth/UserInfo/" + str(identifier) + ) + return resp2.status == 200 + except Exception: + return False + + async def mktoken(self, login, passw): + """Perform the Synergia OAuth dance and keep the resulting cookies.""" + self.credentials_rejected = False + + # Existing cookies may still be valid (e.g. after a reload). + if self.headers and await self.get_data("Me"): + return True + + try: + async with aiohttp.ClientSession( + headers=BROWSER_HEADERS, + timeout=aiohttp.ClientTimeout(total=self.request_timeout), + ) as session: + # Synergia has to hand us a session before the OAuth grant + # has anything to bind to. + await session.get( + "https://synergia.librus.pl/loguj/portalRodzina?v=%d" + % int(time.time()) + ) + + form = aiohttp.FormData() + form.add_field("action", "login") + form.add_field("login", login) + form.add_field("pass", passw) + resp = await session.post( + "https://api.librus.pl/OAuth/Authorization?client_id=%d" + % OAUTH_CLIENT_ID, + data=form, + ) + try: + payload = await resp.json(content_type=None) + except Exception: + payload = None + if not isinstance(payload, dict): + # Not the JSON verdict at all (outage / captcha / + # maintenance page). + return False + if payload.get("status") != "ok": + self.credentials_rejected = True + return False + + # The device check redirects onwards through Grant and back + # into Synergia. + await session.get( + "https://api.librus.pl/OAuth/Authorization/2FA?client_id=%d" + % OAUTH_CLIENT_ID + ) + + jar = session.cookie_jar.filter_cookies( + yarl.URL("https://synergia.librus.pl") + ) + self.headers = { + key: morsel.value for key, morsel in jar.items() + } + if "DZIENNIKSID" not in self.headers or "SDZIENNIKSID" not in self.headers: + return False + return await self.activate_api_access() + except Exception: + return False + + # --------------------------------------------------------------- helpers + + async def curl(self, link): + status = 0 + for _ in range(self.retries): + try: + async with self.client() as session: + async with session.get(link) as resp: + status = resp.status + if status in (200, 401, 403): + return {"code": status, "text": await resp.text()} + except Exception: + pass + return {"code": status, "text": None} + + async def get_data(self, method): + try: + r = await self.curl(self.host + method) + if r["code"] == 200 and r["text"]: + return json.loads(r["text"]) + except Exception: + return None + return None + + # ------------------------------------------------------------------- data + + async def get_subjects(self): + r = await self.get_data("Subjects") + if r is None: + return None + return {x["Id"]: x["Name"] for x in r.get("Subjects", [])} + + async def get_teachers(self): + r = await self.get_data("Users") + if r is None: + return None + return { + i["Id"]: { + "FirstName": i.get("FirstName", ""), + "LastName": i.get("LastName", ""), + } + for i in r.get("Users", []) + } + + async def get_categories(self): + r = await self.get_data("Grades/Categories") + if r is None: + return None + categories = {} + for i in r.get("Categories", []): + categories[i["Id"]] = { + "Name": i.get("Name", "?"), + "Weight": i.get("Weight", "none"), + } + return categories + + async def get_categories2(self): + r = await self.get_data("HomeWorks/Categories") + if r is None: + return None + return { + i["Id"]: {"Name": i.get("Name", "?")} + for i in r.get("Categories", []) + } + + async def get_comments(self): + r = await self.get_data("Grades/Comments") + if r is None: + return None + return {i["Id"]: {"Text": i.get("Text", "")} for i in r.get("Comments", [])} + + async def get_grades(self): + response, categories, comments, subjects, teachers = await asyncio.gather( + self.get_data("Grades"), + self.get_categories(), + self.get_comments(), + self.get_subjects(), + self.get_teachers(), + ) + if None in (response, categories, comments, subjects, teachers): + return None + + grades = {name: [] for name in subjects.values()} + for x in response.get("Grades", []): + try: + subject = subjects[x["Subject"]["Id"]] + category = categories[x["Category"]["Id"]] + except (KeyError, TypeError): + continue + + comment = "No comment" + try: + comment = comments[x["Comments"][0]["Id"]]["Text"] + except (KeyError, IndexError, TypeError): + pass + + teacher = {"FirstName": "unknown", "LastName": "teacher"} + try: + teacher = teachers[x["AddedBy"]["Id"]] + except (KeyError, TypeError): + pass + + grades[subject].append( + { + "Grade": x.get("Grade", "?"), + "Weight": category["Weight"], + "Comment": comment, + "Category": category["Name"], + "isFinal": bool(x.get("IsFinal") or x.get("IsFinalProposition")), + "isSemester": bool( + x.get("IsSemester") or x.get("IsSemesterProposition") + ), + "separator": bool(x.get("IsSemester")), + "underlined": bool(x.get("IsSemester") or x.get("IsFinal")), + "Semester": x.get("Semester"), + "AddDate": x.get("AddDate"), + "AddedBy": { + "FirstName": teacher.get("FirstName", ""), + "LastName": teacher.get("LastName", ""), + }, + } + ) + return grades + + async def get_me(self): + g, u, w, n = await asyncio.gather( + self.get_data("Me"), + self.get_data("UserProfile"), + self.get_teachers(), + self.get_data("Classes"), + ) + if None in (g, u, w, n): + return None + try: + tutor = w[n["Class"]["ClassTutor"]["Id"]] + return { + "FirstName": g["Me"]["Account"]["FirstName"], + "LastName": g["Me"]["Account"]["LastName"], + "Tutor": tutor, + "SchoolYearStarts": n["Class"]["BeginSchoolYear"], + "SchoolYearMiddles": n["Class"]["EndFirstSemester"], + "SchoolYearEnds": n["Class"]["EndSchoolYear"], + "Type": u["UserProfile"]["UnitType"].title(), + "Class": "%s %s" % (n["Class"]["Number"], n["Class"]["Symbol"].upper()), + } + except (KeyError, TypeError): + return None + + async def get_school(self): + data = await self.get_data("Schools") + return data.get("School") if data else None + + async def get_free_days(self): + r = await self.get_data("SchoolFreeDays") + if r is None: + return None + days = [] + for item in r.get("SchoolFreeDays", []): + item = dict(item) + item.pop("Id", None) + item.pop("Units", None) + days.append(item) + return days + + async def get_teacher_free_days(self): + r = await self.get_data("TeacherFreeDays") + teachers = await self.get_teachers() + if r is None or teachers is None: + return None + days = [] + for item in r.get("TeacherFreeDays", []): + item = dict(item) + item.pop("Id", None) + try: + item["Teacher"] = teachers[item["Teacher"]["Id"]] + except (KeyError, TypeError): + pass + days.append(item) + return days + + async def get_lucky_number(self): + try: + return (await self.get_data("LuckyNumbers"))["LuckyNumber"]["LuckyNumber"] + except (KeyError, TypeError): + return None + + async def get_behaviour_grade(self): + try: + ids = [ + "nothing", + "wzorowe", + "bardzo dobre", + "dobre", + "poprawne", + "nieodpowiednie", + "naganne", + "nothing", + ] + raw = (await self.get_data("BehaviourGrades"))["Grades"][0] + teacher = (await self.get_teachers())[raw["Teacher"]["Id"]] + return { + "Grade": ids[raw["GradeType"]["Id"]], + "Teacher": { + "FirstName": teacher.get("FirstName", ""), + "LastName": teacher.get("LastName", ""), + }, + } + except (KeyError, IndexError, TypeError): + return None + + async def get_classrooms(self): + d = await self.get_data("TimetableEntries") + if d is None: + return {} + classrooms = {} + for x in d.get("TimetableEntries", []): + classroom = x.get("Classroom") + if classroom and "Id" in classroom: + classrooms[str(classroom["Id"])] = { + "Name": classroom.get("Name", ""), + "Symbol": classroom.get("Symbol", classroom.get("Name", "")), + } + return classrooms + + async def get_timetable(self, week=0): + """Return a timetable. week=0 is the current week, 1 the next one.""" + today = datetime.now() + week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=week) + week_end = week_start + timedelta(days=6) + + date_from = week_start.strftime("%Y-%m-%d") + date_to = week_end.strftime("%Y-%m-%d") + + r, additional, classrooms = await asyncio.gather( + self.get_data("Timetables?weekStart=%s" % date_from), + self.get_data( + "Timetables/OtherActivitiesRegister?dateFrom=%s&dateTo=%s" + "&hideOutdatedEntries=false" % (date_from, date_to) + ), + self.get_classrooms(), + ) + + if r is None: + return None + if "Timetable" not in r: + return { + "Timetable": {}, + "weekStart": date_from, + "weekEnd": date_to, + "week": week, + } + + time_to_lesson = {} + for date_key, entries in r["Timetable"].items(): + for entry in entries or []: + if entry: + time_to_lesson[entry[0].get("HourFrom")] = entry[0].get( + "LessonNo", "?" + ) + + timetable = {day: [] for day in DAY_NAMES} + + for date_str, entries in r["Timetable"].items(): + if not entries: + continue + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + continue + day_name = DAY_NAMES[dt.weekday()] + + for entry_list in entries: + if not entry_list: + continue + lesson = entry_list[0] + classroom_name = "?" + try: + room = classrooms.get(str(lesson["Classroom"]["Id"]), {}) + classroom_name = room.get("Symbol") or room.get("Name") or "?" + except (KeyError, TypeError): + pass + try: + teacher = { + "FirstName": lesson["Teacher"].get("FirstName", ""), + "LastName": lesson["Teacher"].get("LastName", ""), + } + except (KeyError, TypeError): + teacher = {"FirstName": "", "LastName": ""} + + subject = lesson.get("Subject") or {} + timetable[day_name].append( + { + "Lesson": lesson.get("LessonNo", "?"), + "Subject": subject.get("Name", "Lesson"), + "isSubstitution": bool(lesson.get("IsSubstitutionClass")), + "isCancelled": bool(lesson.get("IsCanceled")), + "Teacher": teacher, + "HourFrom": lesson.get("HourFrom", ""), + "HourTo": lesson.get("HourTo", ""), + "Classroom": classroom_name, + "Date": date_str, + } + ) + + for item in (additional or {}).get("data", []) or []: + try: + dt = datetime.strptime(item["date"], "%Y-%m-%d") + except (KeyError, ValueError): + continue + day_name = DAY_NAMES[dt.weekday()] + + parts = (item.get("teacherName") or "").split(" ") + last = parts[0] if parts else "" + first = " ".join(parts[1:]) if len(parts) > 1 else "" + + classroom = item.get("classroom") or {} + timetable[day_name].append( + { + "Lesson": time_to_lesson.get(item.get("startTime"), "-"), + "Subject": item.get("title", "Activity"), + "isSubstitution": False, + "isCancelled": False, + "Teacher": {"FirstName": first, "LastName": last}, + "HourFrom": item.get("startTime", ""), + "HourTo": item.get("endTime", ""), + "Classroom": classroom.get("symbol", "?"), + "Date": item["date"], + "isAdditional": True, + } + ) + + final = {} + for day in DAY_NAMES: + if timetable[day]: + timetable[day].sort(key=lambda x: x.get("HourFrom") or "") + final[day] = timetable[day] + + return { + "Timetable": final, + "weekStart": date_from, + "weekEnd": date_to, + "week": week, + } + + async def get_exams(self): + r, teachers, categories, subjects = await asyncio.gather( + self.get_data("HomeWorks"), + self.get_teachers(), + self.get_categories2(), + self.get_subjects(), + ) + if None in (r, teachers, categories, subjects): + return None + + exams = [] + for x in r.get("HomeWorks", []): + try: + lesson = subjects[x["Subject"]["Id"]] + except (KeyError, TypeError): + lesson = "Lesson %s" % x.get("LessonNo", "?") + try: + added_by = teachers[x["CreatedBy"]["Id"]] + except (KeyError, TypeError): + added_by = {"FirstName": "", "LastName": ""} + try: + category = categories[x["Category"]["Id"]]["Name"] + except (KeyError, TypeError): + category = "?" + + exams.append( + { + "Lesson": lesson, + "AddedBy": added_by, + "Type": category, + "StartTime": x.get("TimeFrom"), + "EndTime": x.get("TimeTo"), + "Date": x.get("Date"), + "AddDate": x.get("AddDate"), + "Content": x.get("Content", ""), + } + ) + return exams + + async def get_attendances(self): + g, u, w, n, o = await asyncio.gather( + self.get_data("Attendances"), + self.get_teachers(), + self.get_data("Lessons"), + self.get_subjects(), + self.get_data("Attendances/Types"), + ) + if None in (g, u, w, n, o): + return None + + lessons = { + a["Id"]: a.get("Subject", {}).get("Id") + for a in w.get("Lessons", []) + } + types = { + a["Id"]: { + "isPresence": a.get("IsPresenceKind", True), + "Type": a.get("Name", "?"), + "Short": a.get("Short", "?"), + } + for a in o.get("Types", []) + } + + attends = [] + for x in g.get("Attendances", []): + try: + subject = n[lessons[x["Lesson"]["Id"]]] + except (KeyError, TypeError): + subject = "?" + try: + type_info = types[x["Type"]["Id"]] + except (KeyError, TypeError): + type_info = {"Type": "?", "Short": "?", "isPresence": True} + try: + added_by = u[x["AddedBy"]["Id"]] + except (KeyError, TypeError): + added_by = {"FirstName": "", "LastName": ""} + + attends.append( + { + "Lesson": subject, + "Type": type_info["Type"], + "Short": type_info["Short"], + "isPresence": type_info["isPresence"], + "Added": x.get("AddDate"), + "Date": x.get("Date"), + "AddedBy": added_by, + } + ) + return attends + + async def get_parent_teacher_conferences(self): + data = await self.get_data("ParentTeacherConferences") + return (data or {}).get("ParentTeacherConferences", []) + + async def get_notifications(self, days=14): + grades, exams, attendances, types, conferences = await asyncio.gather( + self.get_data("Grades"), + self.get_data("HomeWorks"), + self.get_data("Attendances"), + self.get_data("Attendances/Types"), + self.get_parent_teacher_conferences(), + ) + presence = { + str(x["Id"]): x.get("IsPresenceKind", True) + for x in (types or {}).get("Types", []) + } + absences = 0 + for attendance in (attendances or {}).get("Attendances", []): + try: + absences += not presence[str(attendance["Type"]["Id"])] + except (KeyError, TypeError): + pass + return { + "grades": len((grades or {}).get("Grades", [])), + "exams": len((exams or {}).get("HomeWorks", [])), + "absences": absences, + "conferences": len(conferences or []), + } + + # --------------------------------------------------------------- messages + + async def get_messages(self): + try: + async with self.client() as session: + resp = await session.get("%s/wiadomosci" % self.msg_host) + html = await resp.text() + soup = BeautifulSoup(html, "html.parser") + table = soup.find("table", class_="decorated stretch") + if table is None: + return [] + messages = [] + for row in table.find_all("tr"): + try: + cols = row.find_all("td") + if len(cols) < 5: + continue + sender = cols[2].get_text(" ", strip=True) + if "(" in sender: + sender = sender[: sender.index("(")].strip() + href = cols[2].find("a", href=True)["href"][1:] + messages.append( + { + "from": sender, + "subject": cols[3].get_text(" ", strip=True), + "date": cols[4].get_text(" ", strip=True), + "link": href[href.index("/") + 1 :].replace("/", "-"), + } + ) + except Exception: + continue + return messages + except Exception: + return [] + + async def get_message(self, link): + try: + async with self.client() as session: + resp = await session.get( + "%s/wiadomosci/%s" + % (self.msg_host, link.replace("-", "/")) + ) + html = await resp.text() + soup = BeautifulSoup(html, "html.parser") + content_div = soup.find("div", class_="container-message-content") + content = content_div.decode_contents() if content_div else "" + + details = {} + for row in soup.find_all("tr"): + cells = row.find_all("td") + for i, cell in enumerate(cells): + label = cell.get_text(" ", strip=True).lower().rstrip(":") + if label in ("temat", "od", "data", "przeczytano") and i + 1 < len(cells): + details[label] = cells[i + 1].get_text(" ", strip=True) + + attachments = [] + seen = set() + for img in soup.find_all("img"): + onclick = str(img.get("onclick") or "") + match = re.search( + r"""(['"])([^'"]*pobierz_zalacznik[^'"]*)\1""", onclick + ) + if not match: + continue + source = match.group(2) + if source.startswith("/"): + source = self.msg_host + source + nice = source.split("/pobierz_zalacznik/")[-1] + if nice in seen: + continue + seen.add(nice) + name = nice + row = img.find_parent("tr") + if row: + first_cell = row.find("td") + if first_cell and first_cell.get_text(strip=True): + name = first_cell.get_text(" ", strip=True) + attachments.append( + { + "name": name, + "source": source, + "direct": "/get", + "nice": nice, + } + ) + + return { + "subject": details.get("temat", "(no subject)"), + "from": details.get("od", ""), + "date": details.get("data", ""), + "read": details.get("przeczytano", ""), + "content": content, + "attachments": attachments, + } + except Exception as exc: # keep the UI alive on markup changes + return { + "subject": "Could not load message", + "from": "system", + "date": "", + "read": "", + "content": "

%s

" % repr(exc), + "attachments": [], + } + + async def download_file(self, link): + try: + async with self.client() as session: + resp = await session.get( + "%s/wiadomosci/pobierz_zalacznik/%s" + % (self.msg_host, link) + ) + resp_f = await session.get("%s/get" % resp.url) + return { + "headers": resp_f.headers, + "content": await resp_f.read(), + } + except Exception: + return None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ace5d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3.9,<4 +beautifulsoup4>=4.12,<5 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..211bc64 --- /dev/null +++ b/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ ! -x .venv/bin/python ]; then + python3 -m venv .venv +fi + +if ! .venv/bin/python -c "import aiohttp, bs4" >/dev/null 2>&1; then + .venv/bin/pip install --quiet --upgrade pip + .venv/bin/pip install --quiet -r requirements.txt +fi + +exec .venv/bin/python app.py "$@" diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..7d17d28 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..270b277 --- /dev/null +++ b/static/index.html @@ -0,0 +1,185 @@ + + + + + + + librus-tui :: synergia + + + + +
+ + + + +
+
+
+ ▚ + librus-tui + :: + synergia gateway +
+
+ + + + --:--:-- +
+
+ + + + + + + + +
+
+ ~$ + + + press : for commands · ? for help +
+
+ ready + + 1-7 views + r refresh + : cmd + jk move + + synergia://offline +
+
+
+ +
+ + +
+ + + + + + + + + diff --git a/static/js/00-core.js b/static/js/00-core.js new file mode 100644 index 0000000..b5bb282 --- /dev/null +++ b/static/js/00-core.js @@ -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 = `
| ${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
+
: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); +} diff --git a/static/js/10-auth.js b/static/js/10-auth.js new file mode 100644 index 0000000..46d890d --- /dev/null +++ b/static/js/10-auth.js @@ -0,0 +1,264 @@ +/* ========================================================================== + librus-tui frontend - authentication, session chrome, clock + ========================================================================== */ + +"use strict"; + +function initClock() { + const update = () => { + const now = new Date(); + const dateElement = $("#date"); + const clockElement = $("#clock"); + if (dateElement) { + dateElement.textContent = now + .toLocaleDateString("en-GB", { + weekday: "short", + day: "2-digit", + month: "short", + year: "numeric", + }) + .toUpperCase(); + } + if (clockElement) { + clockElement.textContent = now.toLocaleTimeString("en-GB", { hour12: false }); + } + }; + update(); + setInterval(update, 1000); +} + +function updateDemoUI() { + const demoButton = $("#demo-btn"); + if (demoButton) demoButton.classList.toggle("hidden", !state.config.demo); + const version = $("#version"); + if (version) version.textContent = state.config.version || "1.0.0"; +} + +function setLoginBusy(busy) { + const loginInput = $("#login"); + const passwordInput = $("#password"); + const loginButton = $("#login-btn"); + const demoButton = $("#demo-btn"); + if (loginInput) loginInput.disabled = busy; + if (passwordInput) passwordInput.disabled = busy; + if (loginButton) { + loginButton.disabled = busy; + loginButton.textContent = busy ? "[ authenticating… ]" : "[ authenticate ]"; + } + if (demoButton) { + demoButton.disabled = busy; + demoButton.textContent = busy ? "[ ... ]" : "[ enter demo mode ]"; + } +} + +function showLogin(message) { + state.session = null; + state.loginBusy = false; + state.loadToken += 1; + state.messageToken += 1; + stopSpinner(); + stopBootTimer(); + setLoginBusy(false); + + const loginScreen = $("#login-screen"); + const appScreen = $("#app-screen"); + if (loginScreen) loginScreen.classList.remove("hidden"); + if (appScreen) appScreen.classList.add("hidden"); + + const demoBadge = $("#demo-badge"); + if (demoBadge) demoBadge.classList.add("hidden"); + const version = $("#version"); + if (version) version.textContent = state.config.version || "1.0.0"; + + setLed("offline"); + setMeta(""); + setStatus("ready"); + const statusRight = $("#status-right"); + if (statusRight) statusRight.textContent = "synergia://offline"; + const sideStatus = $("#side-status"); + if (sideStatus) sideStatus.textContent = "offline"; + const sideName = $("#side-user-name"); + if (sideName) sideName.textContent = "disconnected"; + const sideMeta = $("#side-user-meta"); + if (sideMeta) sideMeta.textContent = "synergia://"; + const prompt = $("#prompt"); + if (prompt) prompt.textContent = "~$"; + + closeCommandBar(); + closeModal(); + document.title = "librus-tui :: synergia"; + history.replaceState(null, "", location.pathname); + + const view = $("#view"); + if (view) view.innerHTML = ""; + + if (message) { + clearBoot(); + appendBoot("err", `!! ${message}`); + appendBoot("", " enter credentials to reconnect"); + } + + updateDemoUI(); + if (state.config.demo) { + appendBoot("warn", "[!] demo mode available — no Librus account required"); + } + + setTimeout(() => { + const loginInput = $("#login"); + if (loginInput) loginInput.focus(); + }, 40); +} + +function enterApp(login, demo) { + state.session = { authenticated: true, login: login || "user", demo: !!demo }; + state.loginBusy = false; + setLoginBusy(false); + stopBootTimer(); + stopSpinner(); + + const loginScreen = $("#login-screen"); + const appScreen = $("#app-screen"); + if (loginScreen) loginScreen.classList.add("hidden"); + if (appScreen) appScreen.classList.remove("hidden"); + + const demoBadge = $("#demo-badge"); + if (demoBadge) demoBadge.classList.toggle("hidden", !demo); + const version = $("#version"); + if (version) version.textContent = state.config.version || "1.0.0"; + + setLed("online"); + setStatus("[ok] session established", "ok"); + const sideStatus = $("#side-status"); + if (sideStatus) sideStatus.textContent = "connected"; + const sideName = $("#side-user-name"); + if (sideName) sideName.textContent = login || "connected"; + const sideMeta = $("#side-user-meta"); + if (sideMeta) sideMeta.textContent = demo ? "demo dataset" : "synergia://connected"; + const statusRight = $("#status-right"); + if (statusRight) statusRight.textContent = demo ? "synergia://demo" : "synergia://connected"; + const prompt = $("#prompt"); + if (prompt) prompt.textContent = `${String(login || "user").toLowerCase()}@librus:~$`; + + toast(demo ? "Demo session started." : "Signed in to Synergia.", "ok"); + + const requested = location.hash.replace(/^#/, ""); + const target = VIEWS.includes(requested) ? requested : "dashboard"; + navigate(target); +} + +function applyUserChrome(bootstrap) { + const me = (bootstrap && bootstrap.me) || {}; + const school = (bootstrap && bootstrap.school) || {}; + const fullName = personName(me) || state.session?.login || "student"; + const sideName = $("#side-user-name"); + if (sideName) sideName.textContent = fullName; + const sideMeta = $("#side-user-meta"); + if (sideMeta) { + const classPart = me.Class ? `class ${me.Class}` : "student"; + const schoolPart = school.Name ? ` · ${truncate(school.Name, 30)}` : ""; + sideMeta.textContent = classPart + schoolPart; + } + const prompt = $("#prompt"); + if (prompt) { + const shortName = (me.FirstName || state.session?.login || "user").toLowerCase(); + prompt.textContent = `${shortName}@librus:~$`; + } + const statusRight = $("#status-right"); + if (statusRight) { + statusRight.textContent = state.session?.demo + ? "synergia://demo" + : "synergia://connected"; + } +} + +async function submitLogin(demo = false) { + if (state.loginBusy) return; + + const loginInput = $("#login"); + const passwordInput = $("#password"); + const login = demo ? "demo" : (loginInput?.value || "").trim(); + const password = demo ? "" : passwordInput?.value || ""; + + if (!demo && (!login || !password)) { + toast("Login and password are required.", "warn"); + (login ? passwordInput : loginInput)?.focus(); + return; + } + + state.loginBusy = true; + setLoginBusy(true); + clearBoot(); + setLed("busy"); + setStatus("[...] authenticating with synergia"); + setMeta("oauth handshake…"); + appendBoot("cmd", " $ librus-tui auth --oauth client_id=46"); + startBootTimer(); + + try { + const result = await api("/api/login", { + method: "POST", + body: demo ? { demo: true } : { login, password }, + }); + + stopBootTimer(); + appendBoot("ok", "[ ok ] credentials accepted"); + appendBoot("info", " session established · cookies held server-side"); + await sleep(420); + appendBoot("cmd", " launching interface …"); + + if (passwordInput) passwordInput.value = ""; + state.session = { + authenticated: true, + login: demo ? "demo" : login, + demo: !!(result && result.demo), + }; + await sleep(240); + enterApp(demo ? "demo" : login, !!(result && result.demo)); + } catch (error) { + stopBootTimer(); + appendBoot("err", `[fail] ${error.message}`); + setLed("offline"); + setStatus("authentication failed", "error"); + setMeta("login failed"); + toast(error.message, "err", 7000); + state.loginBusy = false; + setLoginBusy(false); + passwordInput?.focus(); + } +} + +function handleExpiredSession() { + if (!state.session) return; + showLogin("Session expired or revoked by Synergia."); + toast("Your session expired. Sign in again.", "warn", 6000); +} + +function confirmLogout() { + openModal( + "logout", + `

Close the current Synergia session?

+

Server-side cookies are discarded immediately. Nothing is persisted.

`, + [ + { label: "[ logout ]", action: "logout-confirm", kind: "btn-warn" }, + { label: "[ cancel ]", close: true }, + ] + ); +} + +async function doLogout() { + closeModal(); + const wasLoggedIn = !!(state.session && state.session.authenticated); + if (wasLoggedIn) { + try { + await api("/api/logout", { method: "POST" }); + } catch (error) { + /* the local session is dropped regardless */ + } + } + state.session = null; + state.messages = []; + state.messageIndex = -1; + state.messageCache.clear(); + showLogin("session closed."); + toast("Logged out.", "ok"); +} diff --git a/static/js/20-dashboard.js b/static/js/20-dashboard.js new file mode 100644 index 0000000..d077568 --- /dev/null +++ b/static/js/20-dashboard.js @@ -0,0 +1,220 @@ +/* ========================================================================== + librus-tui frontend - dashboard view + ========================================================================== */ + +"use strict"; + +async function renderDashboard(token) { + const [bootstrap, notifications, timetable, freedays] = await Promise.all([ + api("/api/bootstrap"), + api("/api/notifications").catch(() => null), + api("/api/timetable?week=0").catch(() => null), + api("/api/freedays").catch(() => null), + ]); + + if (token !== state.loadToken) return; + + applyUserChrome(bootstrap); + + const me = bootstrap.me || {}; + const school = bootstrap.school || {}; + const behaviour = bootstrap.behaviour; + const lucky = bootstrap.luckyNumber; + const notif = notifications || {}; + + const now = new Date(); + const todayName = DAY_NAMES[now.getDay()]; + const todayIndex = (now.getDay() + 6) % 7; // Monday = 0 + const todayIso = isoDate(now); + const week = (timetable && timetable.Timetable) || {}; + + let dayName = todayName; + let lessons = week[todayName] || []; + let dayDate = (lessons[0] && lessons[0].Date) || todayIso; + + if (!lessons.length) { + // Prefer the next day that still belongs to the current week. + for (let offset = 1; offset < 7 - todayIndex; offset += 1) { + const candidate = WEEK_ORDER[todayIndex + offset]; + const candidateLessons = week[candidate] || []; + if (candidateLessons.length) { + dayName = candidate; + lessons = candidateLessons; + dayDate = candidateLessons[0].Date || todayIso; + break; + } + } + } + + if (!lessons.length) { + // Weekends: peek at next week so the panel is not empty. + try { + const nextWeek = await api("/api/timetable?week=1"); + if (token !== state.loadToken) return; + const nextData = (nextWeek && nextWeek.Timetable) || {}; + const firstDay = WEEK_ORDER.find((day) => (nextData[day] || []).length); + if (firstDay) { + dayName = firstDay; + lessons = nextData[firstDay]; + dayDate = (lessons[0] && lessons[0].Date) || todayIso; + } + } catch (error) { + /* dashboard works without a preview */ + } + } + + const isToday = dayDate === todayIso; + const schoolFree = ((freedays && freedays.school) || []) + .filter((item) => item && item.DateFrom) + .slice() + .sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0)); + + const upcomingFree = schoolFree + .filter((item) => (daysUntil(item.DateFrom) ?? -1) >= 0) + .slice(0, 4); + + const freeRows = upcomingFree.length + ? upcomingFree + .map( + (item) => ` +
+ ${esc(item.Name || "free day")} + ${esc(countdownLabel(item.DateFrom))} · ${esc(fmtDateShort(item.DateFrom))} +
` + ) + .join("") + : `
no upcoming free days
`; + + const lessonsHtml = renderDashboardLessons(lessons, isToday); + + const html = ` +
+
+
+
+ student profile + ${esc(me.Type || "synergia")} +
+
+
${esc(me.FirstName || "")} ${esc(me.LastName || "")}
+
+ class ${esc(me.Class || "—")} + · tutor ${esc(personName(me.Tutor) || "—")} +
+
+
school
${esc(school.Name || "—")}
+
address
${esc(schoolAddress(school))}
+
year
${esc(fmtDate(me.SchoolYearStarts))} → ${esc(fmtDate(me.SchoolYearEnds))}
+
semester
ends ${esc(fmtDate(me.SchoolYearMiddles))}
+
+
+
+ +
+
lucky numberdaily draw
+
+
${lucky === null || lucky === undefined ? "–" : esc(lucky)}
+
${lucky === null || lucky === undefined ? "not drawn yet" : "today's number"}
+
+
+ +
+
behavioursemester 1
+
+
${behaviour ? esc(titleCase(behaviour.Grade)) : "—"}
+
${behaviour ? esc(personName(behaviour.Teacher) || "—") : "no grade yet"}
+
+
+ +
+
+ counters + click to open a section +
+
+ + + + +
+
+ +
+
+ ${isToday ? "today" : "next school day"} · ${esc(dayName)} + ${esc(fmtDate(dayDate))}${isToday ? "" : " · coming up"} +
+
${lessonsHtml}
+
+ +
+
+ upcoming breaks + ${upcomingFree.length} ahead +
+
${freeRows}
+
+
+
+ `; + + $("#view").innerHTML = html; +} + +function renderDashboardLessons(lessons, isToday) { + if (!lessons || !lessons.length) { + return `
no lessons
`; + } + + const sorted = lessons + .slice() + .sort((a, b) => (timeToMinutes(a.HourFrom) ?? 9999) - (timeToMinutes(b.HourFrom) ?? 9999)); + + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + let currentIndex = -1; + let nextIndex = -1; + + if (isToday) { + for (let index = 0; index < sorted.length; index += 1) { + const lesson = sorted[index]; + const start = timeToMinutes(lesson.HourFrom); + const end = timeToMinutes(lesson.HourTo); + if (start !== null && end !== null && nowMinutes >= start && nowMinutes <= end) { + currentIndex = index; + } else if (nextIndex === -1 && start !== null && start > nowMinutes && !lesson.isCancelled) { + nextIndex = index; + } + } + } + + return `
${sorted + .map((lesson, index) => { + const classes = ["lesson-line"]; + if (index === currentIndex) classes.push("now"); + if (index === nextIndex) classes.push("next"); + if (lesson.isCancelled) classes.push("cancelled"); + const marker = index === currentIndex ? ">" : index === nextIndex ? "»" : "·"; + const room = [lesson.Classroom, personName(lesson.Teacher)].filter(Boolean).join(" · "); + return ` +
+ ${esc(shortTime(lesson.HourFrom) || "—")} + ${marker} + ${esc(lesson.Subject || "lesson")} + ${esc(lesson.Classroom || "")}${lesson.isCancelled ? " [cancelled]" : ""} +
`; + }) + .join("")}
`; +} diff --git a/static/js/22-grades.js b/static/js/22-grades.js new file mode 100644 index 0000000..14ca2d1 --- /dev/null +++ b/static/js/22-grades.js @@ -0,0 +1,167 @@ +/* ========================================================================== + librus-tui frontend - grades view + ========================================================================== */ + +"use strict"; + +async function renderGrades(token) { + const data = await api("/api/grades"); + if (token !== state.loadToken) return; + + const subjects = data.subjects || {}; + const sorted = Object.entries(subjects).sort((a, b) => + String(a[0]).localeCompare(String(b[0]), "pl") + ); + + state.gradesFlat = []; + state.gradeGroups = sorted.map(([subject, grades]) => { + const items = (grades || []).map((grade) => { + const item = { ...grade, _subject: subject, _index: state.gradesFlat.length }; + state.gradesFlat.push(item); + return item; + }); + return { subject, grades: items }; + }); + + const overall = averageLabel(state.gradesFlat); + const gradeCount = state.gradesFlat.length; + + $("#view").innerHTML = ` +
+
+ filter + + + + + average ${esc(overall)} + ${gradeCount} grades + ${state.gradeGroups.length} subjects + +
+
+
+
+ 5 / 6 + 4 + 3 + 1 / 2 + dashed chip = final · outlined = semester +
+
+
+ `; + + const input = $("#grade-filter"); + if (input) { + input.value = state.gradeFilter; + input.addEventListener("input", () => { + state.gradeFilter = input.value; + renderGradesList(); + }); + } + + renderGradesList(); +} + +function gradeWeightLabel(weight) { + const raw = String(weight ?? "").toLowerCase(); + if (!raw || raw === "none" || raw === "null") return ""; + return `w${raw}`; +} + +function renderGradesList() { + const container = $("#grades-list"); + if (!container) return; + + const query = state.gradeFilter.trim().toLowerCase(); + const groups = state.gradeGroups.filter((group) => { + if (!query) return true; + if (group.subject.toLowerCase().includes(query)) return true; + return group.grades.some((grade) => + `${grade.Category || ""} ${grade.Grade || ""}`.toLowerCase().includes(query) + ); + }); + + if (!groups.length) { + container.innerHTML = `
no matching grades
`; + return; + } + + container.innerHTML = groups + .map((group) => { + const average = averageLabel(group.grades); + const chips = group.grades.length + ? group.grades + .map((grade) => { + const weight = gradeWeightLabel(grade.Weight); + const classes = ["grade-chip", gradeClass(grade.Grade)]; + if (grade.isFinal) classes.push("final"); + if (grade.isSemester) classes.push("semester"); + const label = `${group.subject} · ${grade.Grade} · ${grade.Category || "grade"} · ${fmtDate(grade.AddDate)}`; + return ` + `; + }) + .join("") + : `— no grades yet —`; + + return ` +
+
+ ${esc(group.subject)} + avg ${esc(average)} +
+
+
${chips}
+
+
`; + }) + .join(""); +} + +function focusGradeFilter() { + const input = $("#grade-filter"); + if (input) { + input.focus(); + input.select(); + } +} + +function openGradeDetail(index) { + const grade = state.gradesFlat[index]; + if (!grade) return; + + const flags = []; + if (grade.isFinal) flags.push("final"); + if (grade.isSemester) flags.push("semester"); + if (grade.Weight && String(grade.Weight).toLowerCase() !== "none") { + flags.push(`weight ${grade.Weight}`); + } + + const comment = + grade.Comment && grade.Comment !== "No comment" + ? `
${esc(grade.Comment)}
` + : ""; + + const body = ` +
+
subject
${esc(grade._subject)}
+
grade
${esc(grade.Grade)}
+
category
${esc(grade.Category || "—")}
+
weight
${esc(grade.Weight ?? "—")}
+
added
${esc(fmtDate(grade.AddDate))}
+
teacher
${esc(personName(grade.AddedBy) || "—")}
+
semester
${esc(grade.Semester ?? "—")}${flags.length ? ` · ${esc(flags.join(" · "))}` : ""}
+
+ ${comment} + `; + + openModal(`grade ${grade.Grade} · ${grade._subject}`, body, [ + { label: "[ close ]", close: true }, + ]); +} diff --git a/static/js/24-timetable.js b/static/js/24-timetable.js new file mode 100644 index 0000000..96e93e3 --- /dev/null +++ b/static/js/24-timetable.js @@ -0,0 +1,182 @@ +/* ========================================================================== + librus-tui frontend - timetable view + ========================================================================== */ + +"use strict"; + +async function renderTimetable(token) { + const data = await api(`/api/timetable?week=${state.timetableWeek}`); + if (token !== state.loadToken) return; + + const timetable = data.Timetable || {}; + const weekStart = parseDate(data.weekStart) || mondayOfCurrentWeek(state.timetableWeek); + const weekEnd = parseDate(data.weekEnd) || addDays(weekStart, 6); + const weekStartIso = isoDate(weekStart); + const weekEndIso = isoDate(weekEnd); + + const columns = WEEK_ORDER.filter( + (day, index) => index < 5 || (timetable[day] || []).length > 0 + ); + + const numericLessons = []; + let hasExtra = false; + for (const day of columns) { + for (const entry of timetable[day] || []) { + const raw = String(entry.Lesson ?? ""); + if (/^\d+$/.test(raw)) { + numericLessons.push(Number(raw)); + } else { + hasExtra = true; + } + } + } + const maxLesson = Math.max(8, ...numericLessons); + const rows = []; + for (let number = 1; number <= maxLesson; number += 1) rows.push(String(number)); + if (hasExtra) rows.push("extra"); + + const grid = {}; + for (const day of columns) { + grid[day] = new Map(); + for (const entry of timetable[day] || []) { + const raw = String(entry.Lesson ?? ""); + const key = /^\d+$/.test(raw) ? raw : "extra"; + if (!grid[day].has(key)) grid[day].set(key, []); + grid[day].get(key).push(entry); + } + } + + const today = isoDate(); + const weekLabel = + state.timetableWeek === 0 + ? "current week" + : state.timetableWeek === 1 + ? "next week" + : `week +${state.timetableWeek}`; + + const header = columns + .map((day) => { + const index = WEEK_ORDER.indexOf(day); + const date = addDays(weekStart, index); + const dateIso = isoDate(date); + return ` +
+ ${esc(day.slice(0, 3))} + ${esc(fmtDate(dateIso, false))} +
`; + }) + .join(""); + + const body = rows + .map((row) => { + let timeLabel = ""; + if (row !== "extra") { + for (const day of columns) { + const entry = (grid[day].get(row) || [])[0]; + if (entry && entry.HourFrom) { + timeLabel = shortTime(entry.HourFrom); + break; + } + } + } else { + for (const day of columns) { + const entry = (grid[day].get("extra") || [])[0]; + if (entry && entry.HourFrom) { + timeLabel = shortTime(entry.HourFrom); + break; + } + } + } + + const hourCell = ` +
+ ${row === "extra" ? "+" : esc(row)} + ${esc(timeLabel)} +
`; + + const cells = columns + .map((day) => { + const entries = grid[day].get(row) || []; + if (!entries.length) { + return `
·
`; + } + return `
${entries.map(renderTimetableEntry).join("")}
`; + }) + .join(""); + + return hourCell + cells; + }) + .join(""); + + $("#view").innerHTML = ` +
+
+ + + ${esc(weekLabel)} + ${esc(fmtDate(weekStartIso))} → ${esc(fmtDate(weekEndIso))} + + [ ] switch week +
+
+
+
lesson
+ ${header} + ${body} +
+
+
+ + SUB substitution · + CNL cancelled · + EXTRA additional activity + +
+
+ `; +} + +function renderTimetableEntry(entry) { + const flags = []; + if (entry.isSubstitution) flags.push(`SUB`); + if (entry.isCancelled) flags.push(`CNL`); + if (entry.isAdditional) flags.push(`EXTRA`); + + const teacher = personName(entry.Teacher); + const title = [ + entry.Subject, + timeRange(entry.HourFrom, entry.HourTo), + entry.Classroom ? `room ${entry.Classroom}` : "", + teacher, + ] + .filter(Boolean) + .join(" · "); + + return ` +
+ ${esc(entry.Subject || "lesson")} + ${esc(timeRange(entry.HourFrom, entry.HourTo))} + ${esc(entry.Classroom || "?")}${teacher ? ` · ${esc(teacher)}` : ""} + ${flags.length ? `${flags.join("")}` : ""} +
`; +} + +function mondayOfCurrentWeek(week = 0) { + const today = new Date(); + const monday = addDays(today, -((today.getDay() + 6) % 7) + week * 7); + monday.setHours(0, 0, 0, 0); + return monday; +} + +function addDays(date, days) { + const copy = new Date(date.getTime()); + copy.setDate(copy.getDate() + days); + return copy; +} + +function changeTimetableWeek(delta) { + const next = Math.min(3, Math.max(0, state.timetableWeek + delta)); + if (next === state.timetableWeek) return; + state.timetableWeek = next; + navigate("timetable"); +} diff --git a/static/js/26-more-views.js b/static/js/26-more-views.js new file mode 100644 index 0000000..b6f6e53 --- /dev/null +++ b/static/js/26-more-views.js @@ -0,0 +1,385 @@ +/* ========================================================================== + librus-tui frontend - homework, attendance, messages, free days + ========================================================================== */ + +"use strict"; + +/* ------------------------------------------------------------------ homework */ + +async function renderHomework(token) { + const data = await api("/api/exams"); + if (token !== state.loadToken) return; + + const exams = (data.exams || []).slice().sort((a, b) => { + const left = parseDate(a.Date); + const right = parseDate(b.Date); + return (left ? left.getTime() : Number.MAX_SAFE_INTEGER) - + (right ? right.getTime() : Number.MAX_SAFE_INTEGER); + }); + state.exams = exams; + + const upcoming = exams.filter((exam) => (daysUntil(exam.Date) ?? -1) >= 0).length; + + const rows = exams.length + ? exams + .map((exam, index) => { + const distance = daysUntil(exam.Date); + const dateClass = distance === null ? "" : distance < 0 ? "soft" : distance <= 1 ? "urgent" : distance <= 3 ? "soon" : ""; + const typeClass = examTagClass(exam.Type); + const content = exam.Content ? truncate(exam.Content, 84) : "—"; + return ` + + + ${esc(fmtDate(exam.Date))} +
${esc(weekdayShort(exam.Date))}
+ + ${esc(exam.Lesson || "—")} + ${esc(exam.Type || "—")} + ${esc(timeRange(exam.StartTime, exam.EndTime))} + ${esc(personName(exam.AddedBy) || "—")} + ${esc(content)} + `; + }) + .join("") + : ""; + + $("#view").innerHTML = ` +
+
+ ${upcoming} upcoming + · + ${exams.length} total + + click a row for the full description +
+
+
homework & testssorted by date
+ ${ + exams.length + ? `
+ + + + ${rows} +
datesubjecttypetimeteacherdescription
` + : `
no homework or tests
` + } +
+
+ `; +} + +function openExamDetail(index) { + const exam = state.exams[index]; + if (!exam) return; + const body = ` +
+
subject
${esc(exam.Lesson || "—")}
+
type
${esc(exam.Type || "—")}
+
date
${esc(fmtDate(exam.Date))} · ${esc(weekdayShort(exam.Date))}
+
time
${esc(timeRange(exam.StartTime, exam.EndTime))}
+
teacher
${esc(personName(exam.AddedBy) || "—")}
+
added
${esc(fmtDate(exam.AddDate))}
+
+
description
+

${esc(exam.Content || "—")}

+ `; + openModal(`homework · ${exam.Lesson || "—"}`, body, [{ label: "[ close ]", close: true }]); +} + +/* ---------------------------------------------------------------- attendance */ + +async function renderAttendance(token) { + const data = await api("/api/attendances"); + if (token !== state.loadToken) return; + + const items = (data.attendances || []).slice().sort((a, b) => { + const left = parseDate(a.Date); + const right = parseDate(b.Date); + return (right ? right.getTime() : 0) - (left ? left.getTime() : 0); + }); + + const total = items.length; + const absent = items.filter((item) => !item.isPresence).length; + const late = items.filter((item) => /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`)).length; + const excused = items.filter((item) => /zwoln|zw\./i.test(`${item.Type} ${item.Short}`)).length; + + const rows = items.length + ? items + .map((item) => { + const isLate = /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`); + const tagClass = !item.isPresence ? "tag-red" : isLate ? "tag-amber" : "tag-green"; + return ` + + ${esc(fmtDate(item.Date))} + ${esc(item.Lesson || "—")} + ${esc(item.Short || "?")} ${esc(item.Type || "")} + ${esc(personName(item.AddedBy) || "—")} + ${item.isPresence ? "present" : "absent"} + `; + }) + .join("") + : ""; + + $("#view").innerHTML = ` +
+
+
${total}
entries
+
${Math.max(0, total - absent)}
present
+
${absent}
absences
+
${late}
late · ${excused} excused
+
+
+
attendance register${total} entries
+ ${ + items.length + ? `
+ + ${rows} +
datesubjecttypeteacherstatus
` + : `
no attendance entries
` + } +
+
+ `; +} + +/* ------------------------------------------------------------------ messages */ + +async function renderMessages(token) { + const data = await api("/api/messages"); + if (token !== state.loadToken) return; + + state.messages = data.messages || []; + state.messageCache.clear(); + state.messageIndex = state.messages.length ? 0 : -1; + + $("#view").innerHTML = ` +
+
+
+
+ inbox + ${state.messages.length} messages +
+
+
+
+
+
+ `; + + renderMessageList(); + + if (state.messageIndex >= 0) { + loadMessage(state.messageIndex); + } else { + $("#message-reader").innerHTML = ` +
reader
+
inbox empty
`; + } +} + +function renderMessageList() { + const list = $("#message-list"); + if (!list) return; + if (!state.messages.length) { + list.innerHTML = `
no messages
`; + return; + } + list.innerHTML = state.messages + .map( + (message, index) => ` + ` + ) + .join(""); +} + +function highlightMessageList() { + $$("#message-list .message-item").forEach((element, index) => { + element.classList.toggle("active", index === state.messageIndex); + }); + const active = $("#message-list .message-item.active"); + if (active) active.scrollIntoView({ block: "nearest" }); +} + +function selectMessage(index) { + if (!state.messages.length) return; + const next = Math.max(0, Math.min(state.messages.length - 1, index)); + if (next === state.messageIndex) return; + state.messageIndex = next; + highlightMessageList(); + loadMessage(next); +} + +function moveMessage(delta) { + if (!state.messages.length) return; + selectMessage(state.messageIndex + delta); +} + +async function loadMessage(index, force = false) { + const message = state.messages[index]; + const reader = $("#message-reader"); + if (!message || !reader) return; + + const token = ++state.messageToken; + const link = message.link; + + if (!force && state.messageCache.has(link)) { + renderMessageDetail(state.messageCache.get(link), index); + return; + } + + reader.innerHTML = ` +
readerloading…
+
[..] opening message…
`; + + try { + const detail = await api(`/api/message?link=${encodeURIComponent(link)}`); + if (token !== state.messageToken) return; + state.messageCache.set(link, detail); + renderMessageDetail(detail, index); + setStatus(`[ok] message ${index + 1}/${state.messages.length}`, "ok"); + } catch (error) { + if (token !== state.messageToken) return; + reader.innerHTML = ` +
readererror
+
${esc(error.message)}
`; + toast(error.message, "err"); + } +} + +function renderMessageDetail(detail, index) { + const reader = $("#message-reader"); + if (!reader) return; + + const attachments = (detail.attachments || []) + .map((attachment) => { + const link = attachment.nice || attachment.source || ""; + return ` + + [↓] + ${esc(attachment.name || link || "attachment")} + download + `; + }) + .join(""); + + const readState = detail.read + ? `read: ${esc(detail.read)}` + : ""; + + reader.innerHTML = ` +
+ reader + ${index + 1} / ${state.messages.length} +
+
+

${esc(detail.subject || "(no subject)")}

+
+ from ${esc(detail.from || "unknown")} + ${esc(detail.date || "")} + ${readState} +
+
+
${detail.content || `
message body is empty
`}
+ ${ + attachments + ? `
attachments
${attachments}
` + : "" + } + `; + reader.scrollTop = 0; +} + +/* ----------------------------------------------------------------- free days */ + +async function renderFreedays(token) { + const [free, conferences] = await Promise.all([ + api("/api/freedays"), + api("/api/conferences").catch(() => null), + ]); + if (token !== state.loadToken) return; + + const school = (free.school || []) + .slice() + .sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0)); + const teachers = (free.teachers || []) + .slice() + .sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0)); + const conf = (conferences && conferences.conferences) || []; + + const schoolRows = school.length + ? school + .map((item) => { + const distance = daysUntil(item.DateFrom); + const past = distance !== null && distance < 0; + return ` +
+
+
${esc(item.Name || "free day")}
+
${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}
+
+ ${esc(countdownLabel(item.DateFrom))} +
`; + }) + .join("") + : `
no free days
`; + + const teacherRows = teachers.length + ? teachers + .map((item) => { + const teacher = personName(item.Teacher) || "teacher"; + const times = item.TimeFrom + ? ` · ${esc(shortTime(item.TimeFrom))}–${esc(shortTime(item.TimeTo))}` + : ""; + return ` +
+
+
${esc(teacher)}
+
${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}${times}
+
+ ${esc(countdownLabel(item.DateFrom))} +
`; + }) + .join("") + : `
no teacher absences
`; + + const confRows = conf.length + ? conf + .map( + (item) => ` +
+
${esc(item.Name || "conference")}
+
${esc(item.Topic || "")}
+
${esc(fmtDate(item.Date))} · ${esc(shortTime(item.Time))}${item.Room ? ` · room ${esc(item.Room)}` : ""}
+
` + ) + .join("") + : `
no scheduled conferences
`; + + $("#view").innerHTML = ` +
+
+
+
school free days${school.length}
+
${schoolRows}
+
+
+
teacher free days${teachers.length}
+
${teacherRows}
+
+
+
parent-teacher conferences${conf.length}
+
${confRows}
+
+
+
+ `; +} diff --git a/static/js/30-app.js b/static/js/30-app.js new file mode 100644 index 0000000..b773471 --- /dev/null +++ b/static/js/30-app.js @@ -0,0 +1,493 @@ +/* ========================================================================== + librus-tui frontend - navigation, commands, events, bootstrap + ========================================================================== */ + +"use strict"; + +function navigate(view) { + if (!state.session || !state.session.authenticated) return; + const target = VIEWS.includes(view) ? view : "dashboard"; + + state.view = target; + if (target !== "grades") state.gradeFilter = ""; + + $$("#sidebar .nav-item[data-view]").forEach((element) => { + element.classList.toggle("active", element.dataset.view === target); + }); + + const title = $("#view-title"); + if (title) title.textContent = VIEW_TITLES[target] || target; + document.title = `librus-tui :: ${target}`; + const display = $("#command-display"); + if (display) display.textContent = `synergia:${target}`; + closeModal(); + + history.replaceState(null, "", `#${target}`); + + const viewElement = $("#view"); + if (viewElement) viewElement.scrollTop = 0; + + loadView(target); +} + +async function loadView(view) { + if (!state.session || !state.session.authenticated) return; + const token = ++state.loadToken; + setViewLoading(`loading ${VIEW_TITLES[view] || view}…`); + + try { + switch (view) { + case "grades": + await renderGrades(token); + break; + case "timetable": + await renderTimetable(token); + break; + case "homework": + await renderHomework(token); + break; + case "attendance": + await renderAttendance(token); + break; + case "messages": + await renderMessages(token); + break; + case "freedays": + await renderFreedays(token); + break; + case "dashboard": + default: + await renderDashboard(token); + break; + } + + if (token !== state.loadToken) return; + setViewReady(); + } catch (error) { + if (token !== state.loadToken) return; + if (error instanceof ApiError && error.status === 401) return; + renderViewError(error); + } +} + +function renderViewError(error) { + stopSpinner(); + setLed("offline"); + setStatus(`[fail] ${error.message}`, "error"); + setMeta("error"); + const message = esc(error.message || "Unknown error."); + $("#view").innerHTML = ` +
+
signal lostupstream error
+
+ ${message} +

+ The Synergia API may be temporarily unavailable, or the session may have expired. + Check the server log for details. +
+
+ + +
+
`; +} + +function refreshCurrent() { + if (!state.session || !state.session.authenticated) return; + loadView(state.view); +} + +function toggleFullscreen() { + try { + if (document.fullscreenElement) { + document.exitFullscreen(); + toast("[ok] exited fullscreen", "ok", 1400); + } else { + document.documentElement.requestFullscreen(); + toast("[ok] entering fullscreen", "ok", 1400); + } + } catch (error) { + toast(`[fail] ${error.message || "fullscreen unavailable"}`, "warn"); + } +} + +/* ------------------------------------------------------------------ commands */ + +function openCommandBar() { + const line = $("#command-line"); + const input = $("#command-input"); + const hint = $("#command-hint"); + const display = $("#command-display"); + if (!line || !input) return; + line.classList.add("command-mode"); + input.classList.remove("hidden"); + if (hint) hint.classList.add("hidden"); + if (display) display.textContent = ""; + input.value = ""; + input.focus(); +} + +function closeCommandBar() { + const line = $("#command-line"); + const input = $("#command-input"); + const hint = $("#command-hint"); + const display = $("#command-display"); + if (line) line.classList.remove("command-mode"); + if (input) { + input.classList.add("hidden"); + input.value = ""; + } + if (hint) hint.classList.remove("hidden"); + if (display) { + display.textContent = state.session?.authenticated ? `synergia:${state.view}` : ""; + } +} + +function runCommand(raw) { + const text = String(raw || "").trim().replace(/^:/, ""); + if (!text) return; + + state.commandHistory.push(text); + if (state.commandHistory.length > 60) state.commandHistory.shift(); + state.commandPosition = -1; + + const parts = text.split(/\s+/); + const command = parts[0].toLowerCase(); + const argument = parts.slice(1).join(" "); + + if (VIEW_ALIASES[command]) { + navigate(VIEW_ALIASES[command]); + return; + } + + switch (command) { + case "reload": + case "refresh": + case "r": + refreshCurrent(); + toast(`Refreshing ${VIEW_TITLES[state.view] || state.view}…`, "ok", 1800); + break; + case "logout": + case "q": + confirmLogout(); + break; + case "help": + case "?": + openHelpModal(); + break; + case "week": { + const week = Number.parseInt(argument, 10); + if (!Number.isFinite(week) || week < 0 || week > 3) { + toast("Usage: :week 0..3", "warn"); + break; + } + state.timetableWeek = week; + navigate("timetable"); + break; + } + case "goto": { + const target = VIEW_ALIASES[String(argument).toLowerCase()]; + if (target) navigate(target); + else toast(`Unknown section: ${argument || "(empty)"}`, "warn"); + break; + } + case "status": + toast( + `session ${state.session?.login || "—"} · ${state.session?.demo ? "demo" : "live"} · view ${state.view}` + ); + break; + case "clear": + $("#view").innerHTML = `
screen cleared
`; + setStatus("[ok] screen cleared", "ok"); + break; + case "fullscreen": + case "fs": + toggleFullscreen(); + break; + default: + toast(`Unknown command: ${command}. Try :help.`, "warn"); + break; + } +} + +/* ------------------------------------------------------------------- actions */ + +function handleAction(action) { + switch (action) { + case "refresh": + refreshCurrent(); + break; + case "help": + openHelpModal(); + break; + case "logout": + confirmLogout(); + break; + case "logout-confirm": + doLogout(); + break; + case "tt-prev": + changeTimetableWeek(-1); + break; + case "tt-next": + changeTimetableWeek(1); + break; + case "grades-clear": { + state.gradeFilter = ""; + const input = $("#grade-filter"); + if (input) { + input.value = ""; + input.focus(); + } + renderGradesList(); + break; + } + case "retry": + loadView(state.view); + break; + default: + break; + } +} + +/* -------------------------------------------------------------------- events */ + +function bindEvents() { + const loginForm = $("#login-form"); + if (loginForm) { + loginForm.addEventListener("submit", (event) => { + event.preventDefault(); + submitLogin(false); + }); + } + + const demoButton = $("#demo-btn"); + if (demoButton) { + demoButton.addEventListener("click", () => submitLogin(true)); + } + + const commandInput = $("#command-input"); + if (commandInput) { + commandInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + const value = commandInput.value; + closeCommandBar(); + runCommand(value); + } else if (event.key === "Escape") { + event.preventDefault(); + closeCommandBar(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (state.commandHistory.length) { + if (state.commandPosition === -1) state.commandPosition = state.commandHistory.length - 1; + else state.commandPosition = Math.max(0, state.commandPosition - 1); + commandInput.value = state.commandHistory[state.commandPosition]; + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + if (state.commandPosition !== -1) { + state.commandPosition = Math.min( + state.commandHistory.length - 1, + state.commandPosition + 1 + ); + commandInput.value = state.commandHistory[state.commandPosition] || ""; + } + } + }); + } + + const viewElement = $("#view"); + if (viewElement) { + viewElement.addEventListener("keydown", (event) => { + if (event.key === "Escape" && event.target && event.target.id === "grade-filter") { + event.target.blur(); + } + }); + } + + document.addEventListener("click", (event) => { + const close = event.target.closest("[data-modal-close]"); + if (close) { + event.preventDefault(); + closeModal(); + return; + } + + const viewTarget = event.target.closest("[data-view]"); + if (viewTarget) { + event.preventDefault(); + navigate(viewTarget.dataset.view); + return; + } + + const actionTarget = event.target.closest("[data-action]"); + if (actionTarget && !actionTarget.disabled) { + event.preventDefault(); + handleAction(actionTarget.dataset.action); + return; + } + + const grade = event.target.closest("[data-grade]"); + if (grade) { + openGradeDetail(Number(grade.dataset.grade)); + return; + } + + const exam = event.target.closest("[data-exam]"); + if (exam) { + openExamDetail(Number(exam.dataset.exam)); + return; + } + + const message = event.target.closest("[data-message]"); + if (message) { + selectMessage(Number(message.dataset.message)); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + + const modalOpen = !$("#modal-root").classList.contains("hidden"); + if (modalOpen) { + if (event.key === "Escape") { + event.preventDefault(); + closeModal(); + } + return; + } + + const appVisible = !$("#app-screen").classList.contains("hidden"); + if (!appVisible) return; + + const active = document.activeElement; + const typing = + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.tagName === "SELECT"); + if (typing) return; + + const viewElement = $("#view"); + + if (event.key === ":") { + event.preventDefault(); + openCommandBar(); + return; + } + + if (event.key === "?") { + event.preventDefault(); + openHelpModal(); + return; + } + + if (event.key >= "1" && event.key <= "7") { + event.preventDefault(); + navigate(VIEWS[Number(event.key) - 1]); + return; + } + + if (event.key === "r" || event.key === "R") { + event.preventDefault(); + refreshCurrent(); + return; + } + + if (event.key === "q") { + event.preventDefault(); + confirmLogout(); + return; + } + + if (event.key === "/" && state.view === "grades") { + event.preventDefault(); + focusGradeFilter(); + return; + } + + if ((event.key === "[" || event.key === "]") && state.view === "timetable") { + event.preventDefault(); + changeTimetableWeek(event.key === "]" ? 1 : -1); + return; + } + + if (event.key === "j" || event.key === "ArrowDown") { + event.preventDefault(); + if (state.view === "messages") moveMessage(1); + else if (viewElement) viewElement.scrollBy({ top: 76, behavior: "smooth" }); + return; + } + + if (event.key === "k" || event.key === "ArrowUp") { + event.preventDefault(); + if (state.view === "messages") moveMessage(-1); + else if (viewElement) viewElement.scrollBy({ top: -76, behavior: "smooth" }); + return; + } + + if (event.key === "g") { + event.preventDefault(); + if (state.view === "messages") selectMessage(0); + else if (viewElement) viewElement.scrollTo({ top: 0, behavior: "smooth" }); + return; + } + + if (event.key === "G") { + event.preventDefault(); + if (state.view === "messages") selectMessage(state.messages.length - 1); + else if (viewElement) viewElement.scrollTo({ top: viewElement.scrollHeight, behavior: "smooth" }); + return; + } + + if (event.key === "Enter" && state.view === "messages" && active?.tagName !== "A") { + event.preventDefault(); + loadMessage(state.messageIndex, true); + } + }); + + window.addEventListener("hashchange", () => { + const target = location.hash.replace(/^#/, ""); + if ( + state.session && + state.session.authenticated && + VIEWS.includes(target) && + target !== state.view + ) { + navigate(target); + } + }); +} + +/* ------------------------------------------------------------------ bootstrap */ + +async function init() { + initClock(); + bindEvents(); + + try { + state.config = await api("/api/config"); + } catch (error) { + state.config = { demo: false, version: "1.0.0" }; + } + updateDemoUI(); + + try { + const session = await api("/api/session"); + if (session && session.authenticated) { + state.session = { + authenticated: true, + login: session.login, + demo: !!session.demo, + }; + enterApp(session.login, session.demo); + } else { + showLogin(); + } + } catch (error) { + showLogin(error.message); + } +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..69294a8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,2157 @@ +/* ========================================================================== + librus-tui - AMOLED theme + ========================================================================== */ + +:root { + --bg: #000000; + --bg-soft: #000000; + --panel: #000000; + --panel-raised: #0a0a0d; + --panel-title: #0a0a0d; + --accent: #7c9cff; + --fg-bright: #f4f4f5; + --accent-mid: #4f7cff; + --dim: #8a8a94; + --dimmer: #565660; + --line: #26262c; + --line-soft: #16161a; + --amber: #fbbf24; + --amber-dim: #7c5a12; + --red: #fb7185; + --red-dim: #7f1d2d; + --cyan: #67e8f9; + --violet: #c4b5fd; + --black: #000000; + --mono: "JetBrains Mono", "Cascadia Mono", "Cascadia Code", "Fira Code", + "IBM Plex Mono", "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, + Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--fg-bright); + font-family: var(--mono); + font-size: 13px; + line-height: 1.45; + font-variant-ligatures: none; + -webkit-font-smoothing: antialiased; + overflow: hidden; + padding: 0; +} + +::selection { + background: var(--accent); + color: var(--black); +} + +button, +input { + font-family: inherit; + font-size: inherit; +} + +button { + color: inherit; +} + +.hidden { + display: none !important; +} + +.dim { + color: var(--dim); +} + +.accent { + color: var(--amber); +} + +/* ------------------------------------------------------------------ CRT chrome */ + +.crt { + position: relative; + height: 100%; + max-width: 100%; + margin: 0; +} + +.crt-scanlines, +.crt-vignette, +.crt-flicker { + display: none !important; +} + +.terminal { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + background: var(--bg); + border: 0; + box-shadow: none; + overflow: hidden; +} + +/* ------------------------------------------------------------------- titlebar */ + +.titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex: 0 0 29px; + padding: 0 10px; + background: #050508; + border-bottom: 1px solid var(--line); + box-shadow: inset 0 1px 0 rgba(124, 156, 255, 0.4); + color: var(--fg-bright); + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.4px; +} + +.titlebar-name { + color: var(--accent); +} + +.titlebar-mark { + color: var(--accent); + font-size: 14px; +} + +.titlebar-left, +.titlebar-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + white-space: nowrap; +} + +.titlebar-mark { + font-size: 14px; +} + +.titlebar-sep, +.titlebar-sub, +.titlebar-date { + opacity: 0.66; + font-weight: 500; +} + +.titlebar-clock { + font-variant-numeric: tabular-nums; +} + +.titlebar-right { + font-weight: 500; + font-size: 11px; +} + +.badge { + display: inline-block; + padding: 0 5px; + border: 1px solid currentColor; + font-size: 9.5px; + line-height: 15px; + letter-spacing: 1px; + font-weight: 700; +} + +.demo-badge { + color: #3c2a00; + background: var(--amber); + border-color: #3c2a00; +} + +.led { + width: 7px; + height: 7px; + border-radius: 50%; + background: #15151a; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5) inset; +} + +.led.online { + background: var(--accent); + box-shadow: 0 0 6px var(--accent); +} + +.led.busy { + background: var(--amber); + animation: led-pulse 0.7s infinite alternate; +} + +.led.offline { + background: var(--red); +} + +.badge { + display: inline-block; + padding: 0 5px; + border: 1px solid currentColor; + font-size: 9.5px; + line-height: 15px; + letter-spacing: 1px; + font-weight: 700; +} + +.demo-badge { + color: #3c2a00; + background: var(--amber); + border-color: #3c2a00; +} + +.led { + width: 7px; + height: 7px; + border-radius: 50%; + background: #15151a; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35) inset; +} + +.led.online { + background: var(--accent); + box-shadow: 0 0 5px var(--accent); +} + +.led.busy { + background: var(--amber-dim); + animation: led-pulse 0.7s infinite alternate; +} + +.led.offline { + background: var(--red-dim); +} + +@keyframes led-pulse { + from { opacity: 0.35; } + to { opacity: 1; } +} + +/* --------------------------------------------------------------------- screens */ + +.screen { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.login-screen { + display: flex; + overflow: auto; +} + +.login-wrap { + width: min(780px, 100%); + margin: auto; + padding: 34px 22px 26px; + text-align: center; +} + +.logo { + display: inline-block; + margin: 0; + color: var(--accent); + font-size: clamp(7px, 1.25vw, 13.5px); + line-height: 1.12; + text-shadow: + 0 0 7px rgba(124, 156, 255, 0.55), + 0 0 24px rgba(124, 156, 255, 0.18); + user-select: none; +} + +.logo-accent { + color: var(--amber); + text-shadow: 0 0 8px rgba(255, 206, 107, 0.6); +} + +.logo-sub { + margin: 8px 0 30px; + color: var(--dim); + font-size: 11px; + letter-spacing: 2.4px; + text-transform: uppercase; +} + +.login-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + gap: 16px; + text-align: left; +} + +.login-box, +.bootlog-wrap { + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.72); + padding: 12px 14px 14px; + box-shadow: 0 0 18px rgba(0, 0, 0, 0.35) inset; +} + +.box-rule { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + color: var(--dim); + font-size: 9.5px; + letter-spacing: 2px; +} + +.box-rule::before, +.box-rule::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line-soft); +} + +.field { + display: block; + margin-bottom: 13px; +} + +.field-label { + display: block; + margin-bottom: 4px; + color: var(--dim); + font-size: 10px; + letter-spacing: 1.6px; + text-transform: uppercase; +} + +.field-input-wrap { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 8px; + background: #000000; + border: 1px solid var(--line); + transition: border-color 0.12s, box-shadow 0.12s; +} + +.field-input-wrap:focus-within { + border-color: var(--accent-mid); + box-shadow: 0 0 0 1px rgba(124, 156, 255, 0.12), 0 0 14px rgba(124, 156, 255, 0.1) inset; +} + +.field-prompt { + color: var(--accent-mid); +} + +.field input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--fg-bright); + caret-color: var(--accent); +} + +.field input::placeholder { + color: #565660; +} + +.btn { + display: inline-block; + padding: 7px 11px; + border: 1px solid var(--line); + background: transparent; + color: var(--accent); + font-size: 12px; + letter-spacing: 0.5px; + cursor: pointer; + text-align: center; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} + +.btn:hover, +.btn:focus-visible { + background: rgba(124, 156, 255, 0.12); + border-color: var(--accent-mid); + color: var(--fg-bright); +} + +.btn:active { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.45; + cursor: wait; +} + +.btn-primary { + width: 100%; + margin-top: 4px; + border-color: var(--accent-mid); + background: rgba(124, 156, 255, 0.08); + color: var(--fg-bright); + text-transform: uppercase; + letter-spacing: 1.6px; + font-size: 11px; +} + +.login-alt { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.login-note { + color: var(--dimmer); + font-size: 9.5px; + letter-spacing: 0.8px; +} + +#demo-btn { + border-color: var(--amber-dim); + color: var(--amber); + font-size: 11px; +} + +#demo-btn:hover { + background: rgba(255, 206, 107, 0.1); + border-color: var(--amber); +} + +.bootlog-wrap { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.bootlog { + flex: 1; + margin: 0; + min-height: 142px; + max-height: 230px; + overflow: auto; + color: var(--dim); + font-family: inherit; + font-size: 11px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +.bootlog .ok { color: var(--accent); } +.bootlog .err { color: var(--red); } +.bootlog .info { color: var(--cyan); } +.bootlog .warn { color: var(--amber); } +.bootlog .cmd { color: var(--fg-bright); } + +.login-hint { + margin: 18px 0 0; + color: var(--dimmer); + font-size: 10px; + line-height: 1.6; +} + +/* ------------------------------------------------------------------- app shell */ + +.app-screen { + display: flex; +} + +.app-grid { + display: grid; + grid-template-columns: 206px minmax(0, 1fr); + width: 100%; + height: 100%; + min-height: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1px; + min-height: 0; + padding: 10px 8px 8px; + border-right: 1px solid var(--line-soft); + background: rgba(0, 0, 0, 0.45); + overflow-y: auto; +} + +.side-head { + padding: 0 8px 7px; + color: var(--dimmer); + font-size: 9px; + letter-spacing: 2.2px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 5px 8px; + border: 1px solid transparent; + background: transparent; + color: var(--dim); + text-align: left; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.nav-item:hover { + color: var(--fg-bright); + background: rgba(124, 156, 255, 0.06); +} + +.nav-item.active { + background: rgba(124, 156, 255, 0.14); + border-color: rgba(124, 156, 255, 0.42); + color: var(--fg-bright); + font-weight: 700; + box-shadow: inset 2px 0 0 var(--accent); +} + +.nav-key { + width: 13px; + flex: 0 0 13px; + color: var(--dimmer); + font-size: 10.5px; + text-align: center; +} + +.nav-item.active .nav-key { + color: var(--accent); +} + +.nav-label { + font-size: 12px; +} + +.nav-muted { + color: var(--dimmer); +} + +.side-sep { + height: 1px; + margin: 7px 8px; + background: var(--line-soft); +} + +.side-spacer { + flex: 1 1 auto; + min-height: 18px; +} + +.side-user { + padding: 8px; + border-top: 1px solid var(--line-soft); +} + +.side-user-name { + color: var(--fg-bright); + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-user-meta { + margin-top: 2px; + color: var(--dim); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-foot { + display: flex; + justify-content: space-between; + padding: 7px 8px 0; + color: var(--dimmer); + font-size: 9px; + letter-spacing: 0.3px; +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.view-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex: 0 0 31px; + padding: 0 13px; + border-bottom: 1px solid var(--line-soft); + background: rgba(0, 0, 0, 0.62); + font-size: 12px; +} + +.view-path { + display: flex; + align-items: center; + gap: 2px; + white-space: nowrap; +} + +.view-path #view-title { + color: var(--fg-bright); +} + +.cursor-block { + display: inline-block; + width: 7px; + height: 13px; + margin-left: 5px; + background: var(--accent); + animation: blink 1.1s steps(1) infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +.view-meta { + color: var(--dim); + font-size: 10px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.view { + flex: 1 1 auto; + min-height: 0; + padding: 13px; + overflow: auto; + outline: none; + scrollbar-gutter: stable; +} + +.view > .view-inner { + max-width: 1400px; + margin: 0 auto; +} + +/* -------------------------------------------------------------------- panels */ + +.panel { + min-width: 0; + border: 1px solid var(--line); + background: var(--bg); + box-shadow: none; +} + +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 8px; + border-bottom: 1px solid var(--line); + background: var(--panel-title); + color: var(--accent); + font-size: 9.5px; + letter-spacing: 1.5px; + text-transform: uppercase; +} + +.panel-title .title-note { + color: var(--dim); + letter-spacing: 0.4px; + text-transform: none; + font-size: 9.5px; +} + +.panel-body { + padding: 10px; +} + +.panel-body.tight { + padding: 6px 8px; +} + +/* ------------------------------------------------------------------ dashboard */ + +.dash-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + align-items: start; +} + +.span-2 { grid-column: span 2; } +.span-3 { grid-column: 1 / -1; } + +.counters-panel { grid-column: span 2; } + +.identity { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; +} + +.identity-name { + margin: 0; + color: var(--fg-bright); + font-size: clamp(20px, 2.1vw, 27px); + line-height: 1.15; + letter-spacing: -0.5px; + text-shadow: 0 0 14px rgba(124, 156, 255, 0.25); +} + +.identity-sub { + margin-top: 3px; + color: var(--dim); + font-size: 11px; +} + +.class-chip { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + border: 1px solid var(--accent-mid); + color: var(--accent); + font-size: 10px; +} + +.kv { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 3px 14px; + margin: 12px 0 0; + font-size: 11px; +} + +.kv dt { + color: var(--dim); +} + +.kv dd { + margin: 0; + color: var(--accent); + overflow-wrap: anywhere; +} + +.lucky { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 128px; + padding: 8px; +} + +.lucky-number { + color: var(--amber); + font-size: clamp(46px, 5.5vw, 68px); + font-weight: 700; + line-height: 1; + text-shadow: + 0 0 12px rgba(255, 206, 107, 0.45), + 0 0 36px rgba(255, 206, 107, 0.15); +} + +.lucky-label { + margin-top: 8px; + color: var(--dim); + font-size: 9.5px; + letter-spacing: 1.6px; + text-transform: uppercase; +} + +.behaviour { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 128px; + padding: 8px; +} + +.behaviour-grade { + color: var(--fg-bright); + font-size: clamp(17px, 1.8vw, 22px); + line-height: 1.2; +} + +.behaviour-teacher { + margin-top: 7px; + color: var(--dim); + font-size: 10.5px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.stat { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + padding: 9px 12px; + border: 0; + border-right: 1px solid var(--line-soft); + background: transparent; + text-align: left; + cursor: pointer; + transition: background 0.1s; +} + +.stat:last-child { + border-right: 0; +} + +.stat:hover { + background: rgba(124, 156, 255, 0.06); +} + +.stat-value { + color: var(--fg-bright); + font-size: 23px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.stat-label { + color: var(--dim); + font-size: 9.5px; + letter-spacing: 1.1px; + text-transform: uppercase; +} + +.stat.warn .stat-value { + color: var(--amber); +} + +.lesson-list { + display: flex; + flex-direction: column; +} + +.lesson-line { + display: grid; + grid-template-columns: 48px 10px minmax(0, 1fr) auto; + gap: 7px; + align-items: baseline; + padding: 4px 2px; + border-bottom: 1px dashed var(--line-soft); + font-size: 11.5px; +} + +.lesson-line:last-child { + border-bottom: 0; +} + +.lesson-line .time { + color: var(--dim); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.lesson-line .marker { + color: var(--dimmer); +} + +.lesson-line .subject { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lesson-line .room { + color: var(--dim); + font-size: 10px; +} + +.lesson-line.now { + background: rgba(124, 156, 255, 0.06); +} + +.lesson-line.now .marker, +.lesson-line.now .subject { + color: var(--fg-bright); +} + +.lesson-line.next .marker { + color: var(--amber); +} + +.lesson-line.cancelled .subject { + color: var(--dimmer); + text-decoration: line-through; +} + +.freeday-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 5px 2px; + border-bottom: 1px dashed var(--line-soft); + font-size: 11.5px; +} + +.freeday-line:last-child { + border-bottom: 0; +} + +.freeday-line .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.countdown { + color: var(--dim); + font-size: 9.5px; + white-space: nowrap; +} + +.countdown.soon { + color: var(--amber); +} + +.countdown.now { + color: var(--fg-bright); +} + +/* ------------------------------------------------------------------- toolbar */ + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; + color: var(--dim); + font-size: 10.5px; +} + +.toolbar .grow { + flex: 1; +} + +.toolbar kbd { + margin: 0 1px; +} + +.tool-input { + width: min(230px, 100%); + padding: 4px 7px; + border: 1px solid var(--line); + background: #000000; + color: var(--fg-bright); + outline: none; +} + +.tool-input:focus { + border-color: var(--accent-mid); + box-shadow: 0 0 0 1px rgba(124, 156, 255, 0.12); +} + +.tool-input::placeholder { + color: var(--dimmer); +} + +.btn-sm { + padding: 3px 8px; + font-size: 10.5px; +} + +.btn-warn { + border-color: var(--amber-dim); + color: var(--amber); +} + +/* -------------------------------------------------------------------- grades */ + +.grades-summary { + display: flex; + gap: 18px; + flex-wrap: wrap; +} + +.grades-summary b { + color: var(--fg-bright); +} + +.grades-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: start; +} + +.subject-card .panel-title { + text-transform: none; + letter-spacing: 0.3px; + font-size: 11px; +} + +.subject-average { + color: var(--amber); + font-size: 10.5px; + white-space: nowrap; +} + +.grade-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.grade-chip { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + min-width: 58px; + padding: 4px 7px 3px; + border: 1px solid var(--line); + border-bottom-width: 2px; + background: #050507; + color: var(--accent); + text-align: left; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, transform 0.1s; +} + +.grade-chip:hover { + background: rgba(124, 156, 255, 0.07); + transform: translateY(-1px); +} + +.grade-chip .g { + font-size: 17px; + font-weight: 700; + line-height: 1.1; +} + +.grade-chip .g-meta { + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dim); + font-size: 8.5px; + letter-spacing: 0.2px; +} + +.grade-chip .g-weight { + position: absolute; + top: 2px; + right: 3px; + color: var(--dimmer); + font-size: 8px; +} + +.grade-chip.g-excellent { border-bottom-color: var(--accent-mid); } +.grade-chip.g-excellent .g { color: var(--fg-bright); } +.grade-chip.g-good { border-bottom-color: var(--cyan); } +.grade-chip.g-good .g { color: var(--cyan); } +.grade-chip.g-ok { border-bottom-color: var(--amber); } +.grade-chip.g-ok .g { color: var(--amber); } +.grade-chip.g-bad { border-bottom-color: var(--red); } +.grade-chip.g-bad .g { color: var(--red); } +.grade-chip.g-none { border-bottom-color: var(--dimmer); } +.grade-chip.g-none .g { color: var(--dim); } + +.grade-chip.final { + border-style: dashed; + background: rgba(124, 156, 255, 0.05); +} + +.grade-chip.semester { + outline: 1px solid var(--line); + outline-offset: 2px; +} + +.legend { + display: flex; + gap: 14px; + flex-wrap: wrap; + color: var(--dim); + font-size: 9.5px; +} + +.legend .swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 4px; + border: 1px solid currentColor; +} + +.legend .swatch.green { color: var(--accent-mid); background: rgba(124, 156, 255, 0.3); } +.legend .swatch.cyan { color: var(--cyan); background: rgba(126, 232, 255, 0.25); } +.legend .swatch.amber { color: var(--amber); background: rgba(255, 206, 107, 0.25); } +.legend .swatch.red { color: var(--red); background: rgba(255, 119, 119, 0.25); } + +/* ----------------------------------------------------------------- timetable */ + +.timetable-scroll { + overflow: auto; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.55); +} + +.timetable { + display: grid; + min-width: 780px; +} + +.tt-cell { + min-width: 0; + min-height: 64px; + padding: 4px 6px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); + font-size: 10.5px; +} + +.tt-cell:nth-child(-n + 5) { + /* header row is handled explicitly */ +} + +.tt-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-direction: column; + gap: 1px; + justify-content: center; + min-height: 34px; + padding: 4px 6px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line); + background: var(--panel-title); + color: var(--accent); + font-size: 10.5px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.tt-head .date { + color: var(--dim); + font-size: 9px; + letter-spacing: 0.2px; + text-transform: none; +} + +.tt-head.today { + background: #0e1428; + color: var(--fg-bright); +} + +.tt-hour { + position: sticky; + left: 0; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + padding: 4px 5px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line-soft); + background: #050507; + color: var(--dim); + font-size: 9.5px; + font-variant-numeric: tabular-nums; +} + +.tt-hour b { + color: var(--accent); + font-size: 11px; +} + +.tt-hour.corner { + position: sticky; + top: 0; + z-index: 4; + background: var(--panel-title); + color: var(--dimmer); + text-align: center; +} + +.tt-lesson + .tt-lesson { + margin-top: 4px; + padding-top: 4px; + border-top: 1px dashed var(--line-soft); +} + +.tt-lesson { + display: flex; + flex-direction: column; + gap: 1px; + height: 100%; +} + +.tt-subject { + color: var(--fg-bright); + line-height: 1.25; + overflow-wrap: anywhere; +} + +.tt-meta { + color: var(--dim); + font-size: 9px; + line-height: 1.3; +} + +.tt-room { + color: var(--cyan); +} + +.tt-flags { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 1px; +} + +.tt-flag { + padding: 0 3px; + border: 1px solid currentColor; + font-size: 7.5px; + line-height: 12px; + letter-spacing: 0.4px; +} + +.tt-flag.sub { color: var(--amber); } +.tt-flag.cancel { color: var(--red); } +.tt-flag.extra { color: var(--cyan); } + +.tt-lesson.cancelled .tt-subject { + color: var(--dimmer); + text-decoration: line-through; +} + +.tt-lesson.cancelled .tt-meta { + color: var(--dimmer); +} + +.tt-empty { + color: var(--dimmer); + font-size: 10px; + text-align: center; + padding-top: 10px; +} + +.tt-free { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--dimmer); +} + +/* --------------------------------------------------------------------- tables */ + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} + +.data-table th { + padding: 5px 7px; + border-bottom: 1px solid var(--line); + color: var(--dim); + font-size: 9px; + font-weight: 500; + letter-spacing: 1.2px; + text-align: left; + text-transform: uppercase; + white-space: nowrap; +} + +.data-table td { + padding: 6px 7px; + border-bottom: 1px dashed var(--line-soft); + vertical-align: top; +} + +.data-table tbody tr:hover { + background: rgba(124, 156, 255, 0.04); +} + +.data-table tbody tr[data-exam] { + cursor: pointer; +} + +.data-table tbody tr:last-child td { + border-bottom: 0; +} + +.data-table .num { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.data-table .strong { + color: var(--fg-bright); +} + +.data-table .soft { + color: var(--dim); +} + +.data-table .content-cell { + color: var(--dim); + max-width: 480px; +} + +.date-badge { + display: inline-block; + padding: 1px 5px; + border: 1px solid var(--line); + color: var(--accent); + font-size: 10px; + white-space: nowrap; +} + +.date-badge.soon { + border-color: var(--amber-dim); + color: var(--amber); +} + +.date-badge.urgent { + border-color: var(--red-dim); + color: var(--red); +} + +.tag { + display: inline-block; + padding: 0 4px; + border: 1px solid currentColor; + font-size: 8.5px; + line-height: 14px; + letter-spacing: 0.4px; + white-space: nowrap; +} + +.tag-green { color: var(--accent-mid); } +.tag-amber { color: var(--amber); } +.tag-red { color: var(--red); } +.tag-cyan { color: var(--cyan); } +.tag-dim { color: var(--dim); } +.tag-violet { color: var(--violet); } + +/* ----------------------------------------------------------------- attendance */ + +.att-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 10px; +} + +.att-tile { + padding: 8px 11px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.6); +} + +.att-tile .value { + color: var(--fg-bright); + font-size: 21px; + line-height: 1.15; +} + +.att-tile .label { + color: var(--dim); + font-size: 9px; + letter-spacing: 1.1px; + text-transform: uppercase; +} + +.att-tile.absent .value { color: var(--red); } +.att-tile.late .value { color: var(--amber); } + +.presence { + font-weight: 700; +} + +.presence.yes { color: var(--accent); } +.presence.no { color: var(--red); } + +/* ------------------------------------------------------------------- messages */ + +.messages-layout { + display: grid; + grid-template-columns: minmax(250px, 0.85fr) minmax(0, 1.35fr); + gap: 10px; + align-items: start; +} + +.message-list { + max-height: min(66vh, 680px); + overflow: auto; +} + +.message-item { + display: block; + width: 100%; + padding: 7px 9px; + border: 0; + border-bottom: 1px dashed var(--line-soft); + background: transparent; + color: var(--fg-bright); + text-align: left; + cursor: pointer; +} + +.message-item:hover { + background: rgba(124, 156, 255, 0.05); +} + +.message-item.active { + background: rgba(124, 156, 255, 0.14); + color: var(--fg-bright); + box-shadow: inset 2px 0 0 var(--accent); +} + +.message-item.active .msg-from, +.message-item.active .msg-date { + color: var(--dim); +} + +.msg-top { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: baseline; +} + +.msg-from { + color: var(--dim); + font-size: 9.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.msg-date { + color: var(--dimmer); + font-size: 9px; + white-space: nowrap; +} + +.msg-subject { + margin-top: 1px; + font-size: 11.5px; + line-height: 1.3; +} + +.msg-unread { + color: var(--amber); +} + +.message-item.active .msg-unread { + color: var(--amber); +} + +.message-reader { + max-height: min(66vh, 680px); + overflow: auto; +} + +.reader-head { + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.reader-subject { + margin: 0; + color: var(--fg-bright); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.reader-meta { + display: flex; + gap: 14px; + flex-wrap: wrap; + margin-top: 5px; + color: var(--dim); + font-size: 10px; +} + +.message-body { + padding: 12px; + color: #d4d4d8; + font-size: 12px; + line-height: 1.65; + overflow-wrap: anywhere; + overflow-x: auto; +} + +.message-body p { + margin: 0 0 0.8em; +} + +.message-body a { + color: var(--cyan); + text-decoration: underline; +} + +.message-body b, +.message-body strong { + color: var(--fg-bright); +} + +.message-body ul, +.message-body ol { + margin: 0 0 0.8em; + padding-left: 22px; +} + +.message-body img { + max-width: 100%; + height: auto; + border: 1px solid var(--line); +} + +.message-body table { + max-width: 100%; + border-collapse: collapse; +} + +.message-body td, +.message-body th { + padding: 3px 6px; + border: 1px solid var(--line-soft); +} + +.message-body blockquote { + margin: 0 0 0.8em; + padding-left: 10px; + border-left: 2px solid var(--line); + color: var(--dim); +} + +.attachments { + padding: 0 12px 12px; +} + +.attachments-title { + margin-bottom: 5px; + color: var(--dim); + font-size: 9px; + letter-spacing: 1.4px; + text-transform: uppercase; +} + +.attachment { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border: 1px solid var(--line-soft); + color: var(--cyan); + font-size: 10.5px; + text-decoration: none; + margin-bottom: 4px; +} + +.attachment:hover { + border-color: var(--cyan); + background: rgba(126, 232, 255, 0.06); +} + +.attachment .size-hint { + margin-left: auto; + color: var(--dim); + font-size: 9px; +} + +/* -------------------------------------------------------------------- free days */ + +.split-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: start; +} + +.free-item { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + padding: 6px 2px; + border-bottom: 1px dashed var(--line-soft); +} + +.free-item:last-child { + border-bottom: 0; +} + +.free-name { + color: var(--accent); +} + +.free-dates { + color: var(--dim); + font-size: 10px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.conf-item { + padding: 6px 2px; + border-bottom: 1px dashed var(--line-soft); +} + +.conf-item:last-child { + border-bottom: 0; +} + +.conf-name { + color: var(--fg-bright); +} + +.conf-meta { + color: var(--dim); + font-size: 10px; + margin-top: 1px; +} + +/* ---------------------------------------------------------------- empty/loading */ + +.empty { + padding: 26px 12px; + color: var(--dimmer); + text-align: center; + font-size: 11px; + letter-spacing: 1px; +} + +.empty::before { + content: "[ "; +} + +.empty::after { + content: " ]"; +} + +.loading { + display: flex; + align-items: center; + gap: 10px; + padding: 30px 8px; + color: var(--dim); + font-size: 11.5px; + letter-spacing: 0.5px; +} + +.spinner { + color: var(--accent); + font-size: 15px; +} + +.error-panel { + max-width: 640px; + margin: 30px auto; + border: 1px solid var(--red-dim); +} + +.error-panel .panel-title { + background: #18070b; + border-bottom-color: var(--red-dim); + color: var(--red); +} + +.error-message { + padding: 14px; + color: #ffb3b3; + font-size: 12px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.error-actions { + padding: 0 14px 14px; +} + +/* --------------------------------------------------------------------- command */ + +.command { + flex: 0 0 auto; + border-top: 1px solid var(--line); + background: #000000; +} + +.command-line { + display: flex; + align-items: center; + gap: 7px; + min-height: 29px; + padding: 2px 10px; + color: var(--dim); + font-size: 11.5px; + overflow: hidden; +} + +.prompt { + color: var(--accent-mid); + white-space: nowrap; +} + +.command-display { + color: var(--fg-bright); + white-space: nowrap; +} + +.command-input { + flex: 1; + min-width: 120px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--fg-bright); + caret-color: var(--accent); +} + +.command-hint { + margin-left: auto; + color: var(--dimmer); + font-size: 9.5px; + white-space: nowrap; +} + +.command-line.command-mode { + background: #08080c; +} + +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 21px; + padding: 0 10px; + background: #050508; + border-top: 1px solid var(--line); + color: var(--dim); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.status-left { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-left.error { color: var(--red); } +.status-left.ok { color: var(--accent); } + +.status-keys { + display: flex; + align-items: center; + gap: 3px; + white-space: nowrap; + opacity: 0.78; +} + +.status-keys kbd { + border-color: var(--line); + background: rgba(0, 0, 0, 0.3); + color: var(--dim); +} + +.status-right { + white-space: nowrap; + opacity: 0.7; +} + +kbd { + display: inline-block; + min-width: 13px; + padding: 0 3px; + border: 1px solid var(--line); + color: var(--dim); + font-family: inherit; + font-size: 9px; + line-height: 14px; + text-align: center; +} + +/* --------------------------------------------------------------------- toasts */ + +.toasts { + position: fixed; + right: 22px; + bottom: 56px; + z-index: 120; + display: flex; + flex-direction: column; + gap: 6px; + max-width: min(380px, calc(100vw - 30px)); + pointer-events: none; +} + +.toast { + padding: 7px 10px; + border: 1px solid var(--line); + background: rgba(8, 8, 10, 0.96); + color: var(--accent); + font-size: 11px; + line-height: 1.45; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5); + animation: toast-in 0.16s ease-out; +} + +.toast.ok { border-color: var(--accent-mid); } +.toast.err { border-color: var(--red-dim); color: #ffb3b3; } +.toast.warn { border-color: var(--amber-dim); color: var(--amber); } + +.toast .toast-prefix { + margin-right: 5px; + font-weight: 700; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* --------------------------------------------------------------------- modal */ + +.modal-root { + position: fixed; + inset: 0; + z-index: 110; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(1px); +} + +.modal-box { + position: relative; + width: min(660px, 100%); + max-height: min(82vh, 720px); + display: flex; + flex-direction: column; + border: 1px solid var(--accent-mid); + background: #000000; + box-shadow: + 0 0 0 1px #000, + 0 0 40px rgba(124, 156, 255, 0.12), + 0 20px 80px rgba(0, 0, 0, 0.65); + outline: none; + animation: modal-in 0.13s ease-out; +} + +@keyframes modal-in { + from { opacity: 0; transform: scale(0.985); } + to { opacity: 1; transform: scale(1); } +} + +.modal-title { + flex: 0 0 auto; + padding: 4px 10px; + background: #0a0a0d; + color: var(--accent); + border-bottom: 1px solid var(--line); + font-size: 10px; + font-weight: 700; + letter-spacing: 1.7px; + text-transform: uppercase; +} + +.modal-body { + flex: 1 1 auto; + padding: 14px; + overflow: auto; + color: #d4d4d8; + font-size: 12px; + line-height: 1.65; +} + +.modal-body p { + margin: 0 0 0.8em; +} + +.modal-body p:last-child { + margin-bottom: 0; +} + +.modal-actions { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 14px 14px; + flex-wrap: wrap; +} + +.modal-actions .btn { + min-width: 92px; +} + +.detail-grid { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 5px 14px; + margin: 0; +} + +.detail-grid dt { + color: var(--dim); + font-size: 10px; + letter-spacing: 0.7px; + text-transform: uppercase; +} + +.detail-grid dd { + margin: 0; + color: var(--fg-bright); + overflow-wrap: anywhere; +} + +.detail-comment { + margin-top: 14px; + padding: 8px 10px; + border-left: 2px solid var(--line); + color: var(--dim); + font-style: italic; +} + +.help-grid { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 18px; + margin: 0; +} + +.help-grid dt { + color: var(--accent); + white-space: nowrap; +} + +.help-grid dd { + margin: 0; + color: var(--dim); +} + +.help-grid code, +.help-section + p code { + color: var(--cyan); +} + +.help-section { + margin: 16px 0 7px; + padding-bottom: 3px; + border-bottom: 1px solid var(--line-soft); + color: var(--accent); + font-size: 9.5px; + letter-spacing: 1.5px; + text-transform: uppercase; +} + +.help-section:first-child { + margin-top: 0; +} + +/* ------------------------------------------------------------------ scrollbars */ + +* { + scrollbar-width: thin; + scrollbar-color: var(--line) transparent; +} + +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #26262c; + border: 2px solid #000000; +} + +::-webkit-scrollbar-thumb:hover { + background: #4f7cff; +} + +/* ------------------------------------------------------------------- responsive */ + +@media (max-width: 1120px) { + .dash-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dash-grid .span-2 { + grid-column: span 2; + } + .dash-grid .identity-panel, + .dash-grid .counters-panel, + .dash-grid .today-panel, + .dash-grid .freedays-panel { + grid-column: span 2; + } + .grades-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + body { + padding: 0; + } + .crt, + .terminal { + height: 100dvh; + } + .terminal { + border: 0; + } + .app-grid { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + .sidebar { + flex-direction: row; + align-items: center; + gap: 2px; + padding: 4px; + border-right: 0; + border-bottom: 1px solid var(--line); + overflow-x: auto; + overflow-y: hidden; + } + .side-head, + .side-sep, + .side-spacer, + .side-user, + .side-foot, + .nav-muted { + display: none; + } + .nav-item { + flex: 0 0 auto; + width: auto; + padding: 4px 7px; + } + .nav-label { + font-size: 10.5px; + } + .messages-layout { + grid-template-columns: 1fr; + } + .message-list, + .message-reader { + max-height: 48vh; + } +} + +@media (max-width: 720px) { + .login-grid { + grid-template-columns: 1fr; + } + .login-wrap { + padding-top: 24px; + } + .logo-sub { + margin-bottom: 20px; + letter-spacing: 1.4px; + } + .login-hint { + display: none; + } + .titlebar-sub, + .titlebar-date { + display: none; + } + .dash-grid, + .split-2, + .att-summary { + grid-template-columns: 1fr; + } + .dash-grid .span-2, + .dash-grid .identity-panel, + .dash-grid .counters-panel, + .dash-grid .today-panel, + .dash-grid .freedays-panel { + grid-column: span 1; + } + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .stat:nth-child(2) { + border-right: 0; + } + .stat:nth-child(-n + 2) { + border-bottom: 1px solid var(--line-soft); + } + .command-hint, + .status-keys { + display: none; + } + .view { + padding: 9px; + } + .view-meta { + display: none; + } + .att-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +}