Crank dat!

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

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.py[cod]
.DS_Store
+144
View File
@@ -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 <http://127.0.0.1:8765>. 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.
+631
View File
@@ -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()
+563
View File
@@ -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": (
"<p>Dzień dobry,</p>"
"<p>wyniki próbnej matury z języka polskiego są już dostępne w dzienniku. "
"Średnia klasy to <b>78%</b>. Gratulacje dla wszystkich, którzy przekroczyli 80%.</p>"
"<p>Omówienie arkusza odbędzie się w <b>poniedziałek</b> na lekcji 3. Proszę przynieść "
"zeszyty i arkusze.</p>"
"<p>Pozdrawiam,<br>Maria Wiśniewska</p>"
),
"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": (
"<p>Przypominam, że w czwartek o <b>15:00</b> w sali B04 odbędą się konsultacje "
"przed sprawdzianem z funkcji kwadratowej.</p>"
"<p>Przygotujcie pytania do zadań z rozdziału 5.</p>"
),
"attachments": [],
},
"1069-1": {
"subject": "Zebranie z rodzicami – 12 listopada",
"from": "Sekretariat (administracja)",
"date": "05.11.2026 16:45",
"read": "Nie",
"content": (
"<p>Uprzejmie informujemy, że zebranie z rodzicami odbędzie się "
"<b>12 listopada 2026 r. o godz. 17:00</b> w sali gimnastycznej.</p>"
"<p>Po spotkaniu ogólnym zapraszamy na spotkania z wychowawcami.</p>"
),
"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": (
"<p>Zapraszam na dodatkowe zajęcia z algorytmiki w każdą środę o 15:30 "
"w sali INF. Pracujemy nad projektami do olimpiady informatycznej.</p>"
),
"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()
+722
View File
@@ -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": "<p>%s</p>" % 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
+2
View File
@@ -0,0 +1,2 @@
aiohttp>=3.9,<4
beautifulsoup4>=4.12,<5
Executable
+14
View File
@@ -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 "$@"
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#050f09"/>
<rect x="4" y="4" width="56" height="56" rx="7" fill="none" stroke="#2f7c4b" stroke-width="2"/>
<rect x="4" y="4" width="56" height="12" rx="7" fill="#7dffa8"/>
<circle cx="11" cy="10" r="2" fill="#050f09"/>
<circle cx="18" cy="10" r="2" fill="#050f09"/>
<circle cx="25" cy="10" r="2" fill="#050f09"/>
<path d="M14 28l8 6-8 6" fill="none" stroke="#7dffa8" stroke-width="3" stroke-linecap="square"/>
<rect x="29" y="38" width="18" height="3" fill="#7dffa8"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

+185
View File
@@ -0,0 +1,185 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>librus-tui :: synergia</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="crt" id="crt">
<div class="crt-scanlines" aria-hidden="true"></div>
<div class="crt-vignette" aria-hidden="true"></div>
<div class="crt-flicker" aria-hidden="true"></div>
<div class="terminal" id="terminal">
<header class="titlebar">
<div class="titlebar-left">
<span class="titlebar-mark">▚</span>
<span class="titlebar-name">librus-tui</span>
<span class="titlebar-sep">::</span>
<span class="titlebar-sub">synergia gateway</span>
</div>
<div class="titlebar-right">
<span class="badge demo-badge hidden" id="demo-badge">DEMO</span>
<span class="led" id="connection-led" title="connection"></span>
<span class="titlebar-date" id="date"></span>
<span class="titlebar-clock" id="clock">--:--:--</span>
</div>
</header>
<!-- ---------------------------------------------------------- login -->
<section class="screen login-screen" id="login-screen">
<div class="login-wrap">
<pre class="logo" aria-label="LIBRUS TUI"> _ ___ ____ ____ _ _ ____
| | |_ _| __ )| _ \| | | / ___|
| | | || _ \| |_) | | | \___ \
| |___ | || |_) | _ &lt;| |_| |___) |
|_____|___|____/|_| \_\\___/|____/ <span class="logo-accent">// TUI</span></pre>
<p class="logo-sub">a terminal frontend for Librus Synergia</p>
<div class="login-grid">
<form class="login-box" id="login-form" autocomplete="on">
<div class="box-rule"><span>AUTHENTICATION</span></div>
<label class="field">
<span class="field-label">login</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="login" name="login" type="text" autocomplete="username"
spellcheck="false" autocapitalize="off" placeholder="synergia login" required>
</span>
</label>
<label class="field">
<span class="field-label">password</span>
<span class="field-input-wrap">
<span class="field-prompt">&gt;</span>
<input id="password" name="password" type="password"
autocomplete="current-password" placeholder="••••••••" required>
</span>
</label>
<button class="btn btn-primary" id="login-btn" type="submit">[ authenticate ]</button>
<div class="login-alt">
<button class="btn hidden" id="demo-btn" type="button">[ enter demo mode ]</button>
<span class="login-note">OAuth 2.0 · gateway api/2.0</span>
</div>
</form>
<div class="bootlog-wrap">
<div class="box-rule"><span>BOOT LOG</span></div>
<pre class="bootlog" id="bootlog" aria-live="polite"></pre>
</div>
</div>
<p class="login-hint">
credentials are exchanged with Librus and kept in server memory only ·
<span class="dim">no cookies are stored in your browser except the session id</span>
</p>
</div>
</section>
<!-- ------------------------------------------------------------- app -->
<section class="screen app-screen hidden" id="app-screen">
<div class="app-grid">
<nav class="sidebar" id="sidebar" aria-label="Sections">
<div class="side-head">NAVIGATION</div>
<button class="nav-item" data-view="dashboard" type="button">
<span class="nav-key">1</span><span class="nav-label">dashboard</span>
</button>
<button class="nav-item" data-view="grades" type="button">
<span class="nav-key">2</span><span class="nav-label">grades</span>
</button>
<button class="nav-item" data-view="timetable" type="button">
<span class="nav-key">3</span><span class="nav-label">timetable</span>
</button>
<button class="nav-item" data-view="homework" type="button">
<span class="nav-key">4</span><span class="nav-label">homework</span>
</button>
<button class="nav-item" data-view="attendance" type="button">
<span class="nav-key">5</span><span class="nav-label">attendance</span>
</button>
<button class="nav-item" data-view="messages" type="button">
<span class="nav-key">6</span><span class="nav-label">messages</span>
</button>
<button class="nav-item" data-view="freedays" type="button">
<span class="nav-key">7</span><span class="nav-label">free days</span>
</button>
<div class="side-sep"></div>
<button class="nav-item nav-muted" data-action="refresh" type="button">
<span class="nav-key">r</span><span class="nav-label">refresh</span>
</button>
<button class="nav-item nav-muted" data-action="help" type="button">
<span class="nav-key">?</span><span class="nav-label">help</span>
</button>
<button class="nav-item nav-muted" data-action="logout" type="button">
<span class="nav-key">q</span><span class="nav-label">logout</span>
</button>
<div class="side-spacer"></div>
<div class="side-user" id="side-user">
<div class="side-user-name" id="side-user-name">connecting…</div>
<div class="side-user-meta" id="side-user-meta">synergia://</div>
</div>
<div class="side-foot">
<span>v<span id="version">1.0.0</span></span>
<span id="side-status">offline</span>
</div>
</nav>
<main class="main">
<div class="view-head">
<div class="view-path">
<span class="dim">synergia:</span><span id="view-title">dashboard</span>
<span class="cursor-block" aria-hidden="true"></span>
</div>
<div class="view-meta" id="view-meta"></div>
</div>
<section class="view" id="view" tabindex="0" aria-live="polite"></section>
</main>
</div>
</section>
<!-- ------------------------------------------------------- status bar -->
<footer class="command">
<div class="command-line" id="command-line">
<span class="prompt" id="prompt">~$</span>
<span class="command-display" id="command-display"></span>
<input class="command-input hidden" id="command-input" type="text"
autocomplete="off" spellcheck="false" aria-label="Command">
<span class="command-hint" id="command-hint">press <kbd>:</kbd> for commands · <kbd>?</kbd> for help</span>
</div>
<div class="statusbar">
<span class="status-left" id="status-left">ready</span>
<span class="status-keys">
<kbd>1</kbd><span class="dim">-</span><kbd>7</kbd> views
<kbd>r</kbd> refresh
<kbd>:</kbd> cmd
<kbd>j</kbd><kbd>k</kbd> move
</span>
<span class="status-right" id="status-right">synergia://offline</span>
</div>
</footer>
</div>
<div class="toasts" id="toasts" aria-live="assertive"></div>
<div class="modal-root hidden" id="modal-root" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-modal-close></div>
<div class="modal-box" id="modal-box" tabindex="-1">
<div class="modal-title" id="modal-title"></div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-actions" id="modal-actions"></div>
</div>
</div>
</div>
<script src="/static/js/00-core.js" defer></script>
<script src="/static/js/10-auth.js" defer></script>
<script src="/static/js/20-dashboard.js" defer></script>
<script src="/static/js/22-grades.js" defer></script>
<script src="/static/js/24-timetable.js" defer></script>
<script src="/static/js/26-more-views.js" defer></script>
<script src="/static/js/30-app.js" defer></script>
</body>
</html>
+481
View File
@@ -0,0 +1,481 @@
/* ==========================================================================
librus-tui frontend - core: state, helpers, API, chrome
========================================================================== */
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays"];
const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const VIEW_TITLES = {
dashboard: "dashboard",
grades: "grades",
timetable: "timetable",
homework: "homework & tests",
attendance: "attendance",
messages: "messages",
freedays: "free days",
};
const VIEW_ALIASES = {
d: "dashboard",
dash: "dashboard",
dashboard: "dashboard",
g: "grades",
grades: "grades",
oceny: "grades",
t: "timetable",
plan: "timetable",
timetable: "timetable",
h: "homework",
hw: "homework",
exams: "homework",
homework: "homework",
sprawdziany: "homework",
a: "attendance",
attendance: "attendance",
frekwencja: "attendance",
m: "messages",
msg: "messages",
messages: "messages",
wiadomosci: "messages",
f: "freedays",
free: "freedays",
freedays: "freedays",
dni: "freedays",
};
const state = {
config: { demo: false, version: "1.0.0" },
session: null,
view: "dashboard",
loadToken: 0,
messageToken: 0,
loginBusy: false,
timetableWeek: 0,
gradeFilter: "",
gradeGroups: [],
gradesFlat: [],
exams: [],
messages: [],
messageIndex: -1,
messageCache: new Map(),
commandHistory: [],
commandPosition: -1,
lastSync: null,
};
let spinnerTimer = null;
let bootTimer = null;
/* ------------------------------------------------------------------ helpers */
const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
}
function personName(person) {
if (!person) return "";
return `${person.FirstName || ""} ${person.LastName || ""}`.trim();
}
function parseDate(value) {
if (!value) return null;
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
function isoDate(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function fmtDate(value, withYear = true) {
const date = parseDate(value);
if (!date) return "—";
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
return withYear ? `${day}.${month}.${date.getFullYear()}` : `${day}.${month}`;
}
function fmtDateShort(value) {
return fmtDate(value, false);
}
function weekdayShort(value) {
const date = parseDate(value);
if (!date) return "";
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getDay()];
}
function daysUntil(value) {
const date = parseDate(value);
if (!date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
date.setHours(0, 0, 0, 0);
return Math.round((date - today) / 86400000);
}
function countdownLabel(value) {
const days = daysUntil(value);
if (days === null) return "";
if (days === 0) return "today";
if (days === 1) return "tomorrow";
if (days === -1) return "yesterday";
if (days > 1) return `in ${days} days`;
return `${-days} days ago`;
}
function countdownClass(value) {
const days = daysUntil(value);
if (days === 0) return "now";
if (days !== null && days > 0 && days <= 7) return "soon";
return "";
}
function shortTime(value) {
if (!value) return "";
const match = String(value).match(/^(\d{1,2}):(\d{2})/);
if (!match) return String(value);
return `${match[1].padStart(2, "0")}:${match[2]}`;
}
function timeRange(from, to) {
const start = shortTime(from);
const end = shortTime(to);
if (start && end) return `${start}–${end}`;
return start || end || "—";
}
function timeToMinutes(value) {
const match = String(value || "").match(/^(\d{1,2}):(\d{2})/);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function truncate(value, length = 90) {
const text = String(value ?? "");
if (text.length <= length) return text;
return `${text.slice(0, Math.max(0, length - 1))}…`;
}
function titleCase(value) {
const text = String(value ?? "");
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
}
function weightNumber(weight) {
const number = Number.parseFloat(String(weight ?? "").replace(",", "."));
return Number.isFinite(number) && number > 0 ? number : 1;
}
function numericGrade(grade) {
const number = Number.parseFloat(String(grade ?? "").replace(",", "."));
return Number.isFinite(number) ? number : null;
}
function gradeClass(grade) {
const number = numericGrade(grade);
if (number === null) return "g-none";
if (number >= 5) return "g-excellent";
if (number >= 4) return "g-good";
if (number >= 3) return "g-ok";
return "g-bad";
}
function subjectAverage(grades) {
let total = 0;
let weights = 0;
for (const grade of grades) {
const value = numericGrade(grade.Grade);
if (value === null) continue;
const weight = weightNumber(grade.Weight);
total += value * weight;
weights += weight;
}
return weights ? total / weights : null;
}
function averageLabel(grades) {
const average = subjectAverage(grades);
return average === null ? "—" : average.toFixed(2);
}
function examTagClass(type) {
const text = String(type || "").toLowerCase();
if (text.includes("sprawdzian") || text.includes("test")) return "tag-red";
if (text.includes("kartk") || text.includes("quiz")) return "tag-amber";
if (text.includes("projekt") || text.includes("wyprac") || text.includes("esej")) return "tag-cyan";
return "tag-dim";
}
function schoolAddress(school) {
if (!school) return "—";
const parts = [school.Street, school.BuildingNumber].filter(Boolean).join(" ");
const city = [school.PostCode, school.Town].filter(Boolean).join(" ");
return [parts, city].filter(Boolean).join(", ") || school.Name || "—";
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/* ---------------------------------------------------------------------- API */
class ApiError extends Error {
constructor(message, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
async function api(path, options = {}) {
const opts = { credentials: "same-origin", ...options };
if (opts.body && typeof opts.body !== "string") {
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
opts.body = JSON.stringify(opts.body);
}
let response;
try {
response = await fetch(path, opts);
} catch (error) {
throw new ApiError("Network error: cannot reach the librus-tui server.", 0);
}
let data = null;
try {
data = await response.json();
} catch (error) {
data = null;
}
if (!response.ok) {
if (
response.status === 401 &&
state.session &&
state.session.authenticated &&
!path.startsWith("/api/login")
) {
handleExpiredSession();
}
const message = (data && data.error) || `Request failed (HTTP ${response.status}).`;
throw new ApiError(message, response.status);
}
return data;
}
/* ------------------------------------------------------------ chrome helpers */
function setLed(kind) {
const led = $("#connection-led");
if (!led) return;
led.classList.remove("online", "busy", "offline");
if (kind) led.classList.add(kind);
}
function setStatus(text, kind = "") {
const element = $("#status-left");
if (!element) return;
element.textContent = text;
element.className = `status-left${kind ? ` ${kind}` : ""}`;
}
function setMeta(text) {
const element = $("#view-meta");
if (element) element.textContent = text;
}
function syncTime() {
return new Date().toLocaleTimeString("en-GB", { hour12: false });
}
function setViewReady(note) {
stopSpinner();
setLed("online");
state.lastSync = new Date();
const label = note || `loaded ${VIEW_TITLES[state.view] || state.view}`;
setMeta(`synced ${syncTime()}`);
setStatus(`[ok] ${label}`, "ok");
const statusRight = $("#status-right");
if (statusRight) statusRight.textContent = "synergia://connected";
}
function startSpinner() {
stopSpinner();
const frames = "|/-\\";
let index = 0;
spinnerTimer = setInterval(() => {
$$(".spinner").forEach((element) => {
element.textContent = frames[index % frames.length];
});
index += 1;
}, 110);
}
function stopSpinner() {
if (spinnerTimer) {
clearInterval(spinnerTimer);
spinnerTimer = null;
}
}
function setViewLoading(label) {
const view = $("#view");
if (!view) return;
view.innerHTML = `<div class="loading"><span class="spinner">|</span> ${esc(label)}</div>`;
setMeta("contacting synergia…");
setStatus(`[...] ${label}`);
setLed("busy");
startSpinner();
}
function toast(message, kind = "ok", duration = 3800) {
const root = $("#toasts");
if (!root) return;
const element = document.createElement("div");
element.className = `toast ${kind}`;
const prefix = document.createElement("span");
prefix.className = "toast-prefix";
prefix.textContent = kind === "err" ? "!!" : kind === "warn" ? "!" : ">>";
element.appendChild(prefix);
element.appendChild(document.createTextNode(message));
root.appendChild(element);
setTimeout(() => {
element.remove();
}, duration);
}
/* --------------------------------------------------------------------- modal */
function openModal(title, bodyHtml, actions = []) {
const root = $("#modal-root");
const titleElement = $("#modal-title");
const bodyElement = $("#modal-body");
const actionsElement = $("#modal-actions");
if (!root || !titleElement || !bodyElement || !actionsElement) return;
titleElement.textContent = title;
bodyElement.innerHTML = bodyHtml;
actionsElement.innerHTML = "";
for (const action of actions) {
const button = document.createElement("button");
button.type = "button";
button.className = `btn${action.kind ? ` ${action.kind}` : ""}`;
button.textContent = action.label;
if (action.close) {
button.dataset.modalClose = "";
} else if (action.action) {
button.dataset.action = action.action;
}
actionsElement.appendChild(button);
}
root.classList.remove("hidden");
const box = $("#modal-box");
if (box) box.focus();
}
function closeModal() {
const root = $("#modal-root");
if (root) root.classList.add("hidden");
}
function openHelpModal() {
const body = `
<p><b>librus-tui</b> is a keyboard-first terminal frontend for the Librus Synergia
student portal. Everything is rendered from the Synergia gateway API.</p>
<div class="help-section">global keys</div>
<dl class="help-grid">
<dt>1 … 7</dt><dd>jump to a section</dd>
<dt>r</dt><dd>refresh the current section</dd>
<dt>:</dt><dd>open the command line</dd>
<dt>?</dt><dd>this help window</dd>
<dt>q</dt><dd>log out</dd>
<dt>j / k</dt><dd>scroll, or move through the inbox</dd>
<dt>g / G</dt><dd>jump to top / bottom</dd>
</dl>
<div class="help-section">sections</div>
<dl class="help-grid">
<dt>grades</dt><dd><kbd>/</kbd> filter · click a grade for details</dd>
<dt>timetable</dt><dd><kbd>[</kbd> previous week · <kbd>]</kbd> next week</dd>
<dt>homework</dt><dd>click a row for the full description</dd>
<dt>messages</dt><dd><kbd>j</kbd>/<kbd>k</kbd> select · <kbd>Enter</kbd> open · attachments download</dd>
</dl>
<div class="help-section">commands</div>
<dl class="help-grid">
<dt>:dashboard :grades …</dt><dd>jump to any section (aliases: d, g, t, h, a, m, f)</dd>
<dt>:week 0..3</dt><dd>select a timetable week</dd>
<dt>:reload</dt><dd>refresh the current section</dd>
<dt>:status</dt><dd>print session information</dd>
<dt>:clear</dt><dd>clear the screen</dd>
<dt>:fullscreen / :fs</dt><dd>toggle browser fullscreen</dd>
<dt>:logout</dt><dd>end the Synergia session</dd>
</dl>
<div class="help-section">about</div>
<p>Synergia client adapted from
<a href="https://github.com/dani3l0/librusik" target="_blank" rel="noopener noreferrer">dani3l0/librusik</a>
(<code>lib/api/librus.py</code>, stable).<br>
Your credentials are exchanged directly with Librus and are never written to disk.</p>
`;
openModal("help", body, [{ label: "[ close ]", close: true }]);
}
/* ----------------------------------------------------------------- boot log */
function appendBoot(kind, text) {
const log = $("#bootlog");
if (!log) return;
const line = document.createElement("span");
if (kind) line.className = kind;
line.textContent = `${text}\n`;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
function clearBoot() {
const log = $("#bootlog");
if (log) log.innerHTML = "";
}
function stopBootTimer() {
if (bootTimer) {
clearInterval(bootTimer);
bootTimer = null;
}
}
function startBootTimer() {
stopBootTimer();
const lines = [
["info", " $ ssh synergia.librus.pl"],
["cmd", " GET /loguj/portalRodzina"],
["cmd", " POST api.librus.pl/OAuth/Authorization?client_id=46"],
["cmd", " GET /OAuth/Authorization/2FA?client_id=46"],
["cmd", " waiting for DZIENNIKSID / SDZIENNIKSID …"],
["cmd", " probing /gateway/api/2.0/Auth/TokenInfo …"],
];
let index = 0;
bootTimer = setInterval(() => {
if (index < lines.length) {
appendBoot(lines[index][0], lines[index][1]);
index += 1;
} else {
stopBootTimer();
}
}, 360);
}
+264
View File
@@ -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",
`<p>Close the current Synergia session?</p>
<p class="dim">Server-side cookies are discarded immediately. Nothing is persisted.</p>`,
[
{ 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");
}
+220
View File
@@ -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) => `
<div class="freeday-line" title="${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}">
<span class="name">${esc(item.Name || "free day")}</span>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))} · ${esc(fmtDateShort(item.DateFrom))}</span>
</div>`
)
.join("")
: `<div class="empty">no upcoming free days</div>`;
const lessonsHtml = renderDashboardLessons(lessons, isToday);
const html = `
<div class="view-inner">
<div class="dash-grid">
<section class="panel span-2 identity-panel">
<div class="panel-title">
<span>student profile</span>
<span class="title-note">${esc(me.Type || "synergia")}</span>
</div>
<div class="panel-body">
<div class="identity-name">${esc(me.FirstName || "")} ${esc(me.LastName || "")}</div>
<div class="identity-sub">
<span class="class-chip">class ${esc(me.Class || "—")}</span>
<span class="dim"> · tutor ${esc(personName(me.Tutor) || "—")}</span>
</div>
<dl class="kv">
<dt>school</dt><dd>${esc(school.Name || "—")}</dd>
<dt>address</dt><dd>${esc(schoolAddress(school))}</dd>
<dt>year</dt><dd>${esc(fmtDate(me.SchoolYearStarts))} → ${esc(fmtDate(me.SchoolYearEnds))}</dd>
<dt>semester</dt><dd>ends ${esc(fmtDate(me.SchoolYearMiddles))}</dd>
</dl>
</div>
</section>
<section class="panel">
<div class="panel-title"><span>lucky number</span><span class="title-note">daily draw</span></div>
<div class="lucky">
<div class="lucky-number">${lucky === null || lucky === undefined ? "–" : esc(lucky)}</div>
<div class="lucky-label">${lucky === null || lucky === undefined ? "not drawn yet" : "today's number"}</div>
</div>
</section>
<section class="panel behaviour-panel">
<div class="panel-title"><span>behaviour</span><span class="title-note">semester 1</span></div>
<div class="behaviour">
<div class="behaviour-grade">${behaviour ? esc(titleCase(behaviour.Grade)) : "—"}</div>
<div class="behaviour-teacher">${behaviour ? esc(personName(behaviour.Teacher) || "—") : "no grade yet"}</div>
</div>
</section>
<section class="panel counters-panel">
<div class="panel-title">
<span>counters</span>
<span class="title-note">click to open a section</span>
</div>
<div class="stat-grid">
<button class="stat" type="button" data-view="grades">
<span class="stat-value">${notif.grades ?? "—"}</span>
<span class="stat-label">grades</span>
</button>
<button class="stat" type="button" data-view="homework">
<span class="stat-value">${notif.exams ?? "—"}</span>
<span class="stat-label">homework</span>
</button>
<button class="stat warn" type="button" data-view="attendance">
<span class="stat-value">${notif.absences ?? "—"}</span>
<span class="stat-label">absences</span>
</button>
<button class="stat" type="button" data-view="freedays">
<span class="stat-value">${notif.conferences ?? "—"}</span>
<span class="stat-label">conferences</span>
</button>
</div>
</section>
<section class="panel span-2 today-panel">
<div class="panel-title">
<span>${isToday ? "today" : "next school day"} · ${esc(dayName)}</span>
<span class="title-note">${esc(fmtDate(dayDate))}${isToday ? "" : " · coming up"}</span>
</div>
<div class="panel-body tight">${lessonsHtml}</div>
</section>
<section class="panel freedays-panel">
<div class="panel-title">
<span>upcoming breaks</span>
<span class="title-note">${upcomingFree.length} ahead</span>
</div>
<div class="panel-body tight">${freeRows}</div>
</section>
</div>
</div>
`;
$("#view").innerHTML = html;
}
function renderDashboardLessons(lessons, isToday) {
if (!lessons || !lessons.length) {
return `<div class="empty">no lessons</div>`;
}
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 `<div class="lesson-list">${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 `
<div class="${classes.join(" ")}" title="${esc(room)}">
<span class="time">${esc(shortTime(lesson.HourFrom) || "—")}</span>
<span class="marker">${marker}</span>
<span class="subject">${esc(lesson.Subject || "lesson")}</span>
<span class="room">${esc(lesson.Classroom || "")}${lesson.isCancelled ? " [cancelled]" : ""}</span>
</div>`;
})
.join("")}</div>`;
}
+167
View File
@@ -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 = `
<div class="view-inner">
<div class="toolbar">
<span>filter</span>
<input class="tool-input" id="grade-filter" type="text"
placeholder="subject or category…" spellcheck="false" autocomplete="off">
<button class="btn btn-sm" type="button" data-action="grades-clear">[ clear ]</button>
<span class="grow"></span>
<span class="grades-summary">
<span>average <b>${esc(overall)}</b></span>
<span><b>${gradeCount}</b> grades</span>
<span><b>${state.gradeGroups.length}</b> subjects</span>
</span>
</div>
<div class="grades-grid" id="grades-list"></div>
<div class="toolbar" style="margin-top:12px">
<div class="legend">
<span><span class="swatch green"></span>5 / 6</span>
<span><span class="swatch cyan"></span>4</span>
<span><span class="swatch amber"></span>3</span>
<span><span class="swatch red"></span>1 / 2</span>
<span class="dim">dashed chip = final · outlined = semester</span>
</div>
</div>
</div>
`;
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 = `<div class="empty" style="grid-column:1/-1">no matching grades</div>`;
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 `
<button class="${classes.join(" ")}" type="button" data-grade="${grade._index}" title="${esc(label)}">
${weight ? `<span class="g-weight">${esc(weight)}</span>` : ""}
<span class="g">${esc(grade.Grade)}</span>
<span class="g-meta">${esc(grade.Category || "—")}</span>
</button>`;
})
.join("")
: `<span class="dim" style="font-size:10.5px">— no grades yet —</span>`;
return `
<section class="panel subject-card">
<div class="panel-title">
<span>${esc(group.subject)}</span>
<span class="subject-average">avg ${esc(average)}</span>
</div>
<div class="panel-body">
<div class="grade-chips">${chips}</div>
</div>
</section>`;
})
.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"
? `<div class="detail-comment">${esc(grade.Comment)}</div>`
: "";
const body = `
<dl class="detail-grid">
<dt>subject</dt><dd>${esc(grade._subject)}</dd>
<dt>grade</dt><dd>${esc(grade.Grade)}</dd>
<dt>category</dt><dd>${esc(grade.Category || "—")}</dd>
<dt>weight</dt><dd>${esc(grade.Weight ?? "—")}</dd>
<dt>added</dt><dd>${esc(fmtDate(grade.AddDate))}</dd>
<dt>teacher</dt><dd>${esc(personName(grade.AddedBy) || "—")}</dd>
<dt>semester</dt><dd>${esc(grade.Semester ?? "—")}${flags.length ? ` · ${esc(flags.join(" · "))}` : ""}</dd>
</dl>
${comment}
`;
openModal(`grade ${grade.Grade} · ${grade._subject}`, body, [
{ label: "[ close ]", close: true },
]);
}
+182
View File
@@ -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 `
<div class="tt-head ${dateIso === today ? "today" : ""}">
<span>${esc(day.slice(0, 3))}</span>
<span class="date">${esc(fmtDate(dateIso, false))}</span>
</div>`;
})
.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 = `
<div class="tt-hour">
<b>${row === "extra" ? "+" : esc(row)}</b>
<span>${esc(timeLabel)}</span>
</div>`;
const cells = columns
.map((day) => {
const entries = grid[day].get(row) || [];
if (!entries.length) {
return `<div class="tt-cell"><div class="tt-free">·</div></div>`;
}
return `<div class="tt-cell">${entries.map(renderTimetableEntry).join("")}</div>`;
})
.join("");
return hourCell + cells;
})
.join("");
$("#view").innerHTML = `
<div class="view-inner">
<div class="toolbar">
<button class="btn btn-sm" type="button" data-action="tt-prev" ${state.timetableWeek <= 0 ? "disabled" : ""}>[ &lt; prev ]</button>
<button class="btn btn-sm" type="button" data-action="tt-next" ${state.timetableWeek >= 3 ? "disabled" : ""}>[ next &gt; ]</button>
<span><b>${esc(weekLabel)}</b></span>
<span class="dim">${esc(fmtDate(weekStartIso))} → ${esc(fmtDate(weekEndIso))}</span>
<span class="grow"></span>
<span class="dim"><kbd>[</kbd> <kbd>]</kbd> switch week</span>
</div>
<div class="timetable-scroll">
<div class="timetable" style="grid-template-columns: 60px repeat(${columns.length}, minmax(150px, 1fr))">
<div class="tt-hour corner">lesson</div>
${header}
${body}
</div>
</div>
<div class="toolbar" style="margin-top:10px">
<span class="dim">
<span class="tag tag-amber">SUB</span> substitution ·
<span class="tag tag-red">CNL</span> cancelled ·
<span class="tag tag-cyan">EXTRA</span> additional activity
</span>
</div>
</div>
`;
}
function renderTimetableEntry(entry) {
const flags = [];
if (entry.isSubstitution) flags.push(`<span class="tt-flag sub">SUB</span>`);
if (entry.isCancelled) flags.push(`<span class="tt-flag cancel">CNL</span>`);
if (entry.isAdditional) flags.push(`<span class="tt-flag extra">EXTRA</span>`);
const teacher = personName(entry.Teacher);
const title = [
entry.Subject,
timeRange(entry.HourFrom, entry.HourTo),
entry.Classroom ? `room ${entry.Classroom}` : "",
teacher,
]
.filter(Boolean)
.join(" · ");
return `
<div class="tt-lesson ${entry.isCancelled ? "cancelled" : ""}" title="${esc(title)}">
<span class="tt-subject">${esc(entry.Subject || "lesson")}</span>
<span class="tt-meta">${esc(timeRange(entry.HourFrom, entry.HourTo))}</span>
<span class="tt-meta"><span class="tt-room">${esc(entry.Classroom || "?")}</span>${teacher ? ` · ${esc(teacher)}` : ""}</span>
${flags.length ? `<span class="tt-flags">${flags.join("")}</span>` : ""}
</div>`;
}
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");
}
+385
View File
@@ -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 `
<tr data-exam="${index}" title="click for details">
<td class="num">
<span class="date-badge ${dateClass}">${esc(fmtDate(exam.Date))}</span>
<div class="soft">${esc(weekdayShort(exam.Date))}</div>
</td>
<td class="strong">${esc(exam.Lesson || "—")}</td>
<td><span class="tag ${typeClass}">${esc(exam.Type || "—")}</span></td>
<td class="num">${esc(timeRange(exam.StartTime, exam.EndTime))}</td>
<td class="soft">${esc(personName(exam.AddedBy) || "—")}</td>
<td class="content-cell">${esc(content)}</td>
</tr>`;
})
.join("")
: "";
$("#view").innerHTML = `
<div class="view-inner">
<div class="toolbar">
<span><b>${upcoming}</b> upcoming</span>
<span class="dim">·</span>
<span><b>${exams.length}</b> total</span>
<span class="grow"></span>
<span class="dim">click a row for the full description</span>
</div>
<section class="panel">
<div class="panel-title"><span>homework &amp; tests</span><span class="title-note">sorted by date</span></div>
${
exams.length
? `<div class="table-wrap"><table class="data-table">
<thead>
<tr><th>date</th><th>subject</th><th>type</th><th>time</th><th>teacher</th><th>description</th></tr>
</thead>
<tbody>${rows}</tbody>
</table></div>`
: `<div class="empty">no homework or tests</div>`
}
</section>
</div>
`;
}
function openExamDetail(index) {
const exam = state.exams[index];
if (!exam) return;
const body = `
<dl class="detail-grid">
<dt>subject</dt><dd>${esc(exam.Lesson || "—")}</dd>
<dt>type</dt><dd>${esc(exam.Type || "—")}</dd>
<dt>date</dt><dd>${esc(fmtDate(exam.Date))} · ${esc(weekdayShort(exam.Date))}</dd>
<dt>time</dt><dd>${esc(timeRange(exam.StartTime, exam.EndTime))}</dd>
<dt>teacher</dt><dd>${esc(personName(exam.AddedBy) || "—")}</dd>
<dt>added</dt><dd>${esc(fmtDate(exam.AddDate))}</dd>
</dl>
<div class="help-section">description</div>
<p style="white-space:pre-wrap">${esc(exam.Content || "—")}</p>
`;
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 `
<tr>
<td class="num">${esc(fmtDate(item.Date))}</td>
<td class="strong">${esc(item.Lesson || "—")}</td>
<td><span class="tag ${tagClass}">${esc(item.Short || "?")} ${esc(item.Type || "")}</span></td>
<td class="soft">${esc(personName(item.AddedBy) || "—")}</td>
<td class="num presence ${item.isPresence ? "yes" : "no"}">${item.isPresence ? "present" : "absent"}</td>
</tr>`;
})
.join("")
: "";
$("#view").innerHTML = `
<div class="view-inner">
<div class="att-summary">
<div class="att-tile"><div class="value">${total}</div><div class="label">entries</div></div>
<div class="att-tile"><div class="value">${Math.max(0, total - absent)}</div><div class="label">present</div></div>
<div class="att-tile absent"><div class="value">${absent}</div><div class="label">absences</div></div>
<div class="att-tile late"><div class="value">${late}</div><div class="label">late · ${excused} excused</div></div>
</div>
<section class="panel">
<div class="panel-title"><span>attendance register</span><span class="title-note">${total} entries</span></div>
${
items.length
? `<div class="table-wrap"><table class="data-table">
<thead><tr><th>date</th><th>subject</th><th>type</th><th>teacher</th><th>status</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`
: `<div class="empty">no attendance entries</div>`
}
</section>
</div>
`;
}
/* ------------------------------------------------------------------ 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 = `
<div class="view-inner">
<div class="messages-layout">
<section class="panel">
<div class="panel-title">
<span>inbox</span>
<span class="title-note">${state.messages.length} messages</span>
</div>
<div class="message-list" id="message-list"></div>
</section>
<div class="panel" id="message-reader"></div>
</div>
</div>
`;
renderMessageList();
if (state.messageIndex >= 0) {
loadMessage(state.messageIndex);
} else {
$("#message-reader").innerHTML = `
<div class="panel-title"><span>reader</span></div>
<div class="empty">inbox empty</div>`;
}
}
function renderMessageList() {
const list = $("#message-list");
if (!list) return;
if (!state.messages.length) {
list.innerHTML = `<div class="empty">no messages</div>`;
return;
}
list.innerHTML = state.messages
.map(
(message, index) => `
<button class="message-item ${index === state.messageIndex ? "active" : ""}" type="button" data-message="${index}">
<div class="msg-top">
<span class="msg-from">${esc(message.from || "unknown")}</span>
<span class="msg-date">${esc(message.date || "")}</span>
</div>
<div class="msg-subject">${esc(message.subject || "(no subject)")}</div>
</button>`
)
.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 = `
<div class="panel-title"><span>reader</span><span class="title-note">loading…</span></div>
<div class="loading">[..] opening message…</div>`;
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 = `
<div class="panel-title"><span>reader</span><span class="title-note">error</span></div>
<div class="error-message">${esc(error.message)}</div>`;
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 `
<a class="attachment" href="/api/download?link=${encodeURIComponent(link)}" download>
<span>[&darr;]</span>
<span>${esc(attachment.name || link || "attachment")}</span>
<span class="size-hint">download</span>
</a>`;
})
.join("");
const readState = detail.read
? `<span class="tag tag-dim">read: ${esc(detail.read)}</span>`
: "";
reader.innerHTML = `
<div class="panel-title">
<span>reader</span>
<span class="title-note">${index + 1} / ${state.messages.length}</span>
</div>
<div class="reader-head">
<h2 class="reader-subject">${esc(detail.subject || "(no subject)")}</h2>
<div class="reader-meta">
<span>from <b>${esc(detail.from || "unknown")}</b></span>
<span>${esc(detail.date || "")}</span>
${readState}
</div>
</div>
<div class="message-body">${detail.content || `<div class="empty">message body is empty</div>`}</div>
${
attachments
? `<div class="attachments"><div class="attachments-title">attachments</div>${attachments}</div>`
: ""
}
`;
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 `
<div class="free-item" ${past ? 'style="opacity:.55"' : ""}>
<div>
<div class="free-name">${esc(item.Name || "free day")}</div>
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}</div>
</div>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
</div>`;
})
.join("")
: `<div class="empty">no free days</div>`;
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 `
<div class="free-item">
<div>
<div class="free-name">${esc(teacher)}</div>
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}${times}</div>
</div>
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
</div>`;
})
.join("")
: `<div class="empty">no teacher absences</div>`;
const confRows = conf.length
? conf
.map(
(item) => `
<div class="conf-item">
<div class="conf-name">${esc(item.Name || "conference")}</div>
<div class="conf-meta">${esc(item.Topic || "")}</div>
<div class="conf-meta">${esc(fmtDate(item.Date))} · ${esc(shortTime(item.Time))}${item.Room ? ` · room ${esc(item.Room)}` : ""}</div>
</div>`
)
.join("")
: `<div class="empty">no scheduled conferences</div>`;
$("#view").innerHTML = `
<div class="view-inner">
<div class="split-2">
<section class="panel">
<div class="panel-title"><span>school free days</span><span class="title-note">${school.length}</span></div>
<div class="panel-body tight">${schoolRows}</div>
</section>
<section class="panel">
<div class="panel-title"><span>teacher free days</span><span class="title-note">${teachers.length}</span></div>
<div class="panel-body tight">${teacherRows}</div>
</section>
<section class="panel span-2">
<div class="panel-title"><span>parent-teacher conferences</span><span class="title-note">${conf.length}</span></div>
<div class="panel-body tight">${confRows}</div>
</section>
</div>
</div>
`;
}
+493
View File
@@ -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 = `
<div class="error-panel">
<div class="panel-title"><span>signal lost</span><span class="title-note">upstream error</span></div>
<div class="error-message">
${message}
<br><br>
<span class="dim">The Synergia API may be temporarily unavailable, or the session may have expired.
Check the server log for details.</span>
</div>
<div class="error-actions">
<button class="btn btn-warn" type="button" data-action="retry">[ retry ]</button>
<button class="btn" type="button" data-action="logout">[ logout ]</button>
</div>
</div>`;
}
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 = `<div class="empty">screen cleared</div>`;
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);
+2157
View File
File diff suppressed because it is too large. Load diff