commit fe13a4d989287a57816e0c00dfbcc1869f1cd704 Author: ~purplebored Date: Sun Nov 8 19:28:03 2026 +0100 Crank dat! 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; + } +}