Crank dat!
This commit is contained in:
17 files changed
+6624
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
aiohttp>=3.9,<4
|
||||
beautifulsoup4>=4.12,<5
|
||||
@@ -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 "$@"
|
||||
@@ -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 |
@@ -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"> _ ___ ____ ____ _ _ ____
|
||||
| | |_ _| __ )| _ \| | | / ___|
|
||||
| | | || _ \| |_) | | | \___ \
|
||||
| |___ | || |_) | _ <| |_| |___) |
|
||||
|_____|___|____/|_| \_\\___/|____/ <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">></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">></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>
|
||||
@@ -0,0 +1,481 @@
|
||||
/* ==========================================================================
|
||||
librus-tui frontend - core: state, helpers, API, chrome
|
||||
========================================================================== */
|
||||
|
||||
"use strict";
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
||||
|
||||
const VIEWS = ["dashboard", "grades", "timetable", "homework", "attendance", "messages", "freedays"];
|
||||
const WEEK_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
const VIEW_TITLES = {
|
||||
dashboard: "dashboard",
|
||||
grades: "grades",
|
||||
timetable: "timetable",
|
||||
homework: "homework & tests",
|
||||
attendance: "attendance",
|
||||
messages: "messages",
|
||||
freedays: "free days",
|
||||
};
|
||||
|
||||
const VIEW_ALIASES = {
|
||||
d: "dashboard",
|
||||
dash: "dashboard",
|
||||
dashboard: "dashboard",
|
||||
g: "grades",
|
||||
grades: "grades",
|
||||
oceny: "grades",
|
||||
t: "timetable",
|
||||
plan: "timetable",
|
||||
timetable: "timetable",
|
||||
h: "homework",
|
||||
hw: "homework",
|
||||
exams: "homework",
|
||||
homework: "homework",
|
||||
sprawdziany: "homework",
|
||||
a: "attendance",
|
||||
attendance: "attendance",
|
||||
frekwencja: "attendance",
|
||||
m: "messages",
|
||||
msg: "messages",
|
||||
messages: "messages",
|
||||
wiadomosci: "messages",
|
||||
f: "freedays",
|
||||
free: "freedays",
|
||||
freedays: "freedays",
|
||||
dni: "freedays",
|
||||
};
|
||||
|
||||
const state = {
|
||||
config: { demo: false, version: "1.0.0" },
|
||||
session: null,
|
||||
view: "dashboard",
|
||||
loadToken: 0,
|
||||
messageToken: 0,
|
||||
loginBusy: false,
|
||||
timetableWeek: 0,
|
||||
gradeFilter: "",
|
||||
gradeGroups: [],
|
||||
gradesFlat: [],
|
||||
exams: [],
|
||||
messages: [],
|
||||
messageIndex: -1,
|
||||
messageCache: new Map(),
|
||||
commandHistory: [],
|
||||
commandPosition: -1,
|
||||
lastSync: null,
|
||||
};
|
||||
|
||||
let spinnerTimer = null;
|
||||
let bootTimer = null;
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
||||
}
|
||||
|
||||
function personName(person) {
|
||||
if (!person) return "";
|
||||
return `${person.FirstName || ""} ${person.LastName || ""}`.trim();
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null;
|
||||
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (!year || !month || !day) return null;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function isoDate(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function fmtDate(value, withYear = true) {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "—";
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return withYear ? `${day}.${month}.${date.getFullYear()}` : `${day}.${month}`;
|
||||
}
|
||||
|
||||
function fmtDateShort(value) {
|
||||
return fmtDate(value, false);
|
||||
}
|
||||
|
||||
function weekdayShort(value) {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "";
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][date.getDay()];
|
||||
}
|
||||
|
||||
function daysUntil(value) {
|
||||
const date = parseDate(value);
|
||||
if (!date) return null;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return Math.round((date - today) / 86400000);
|
||||
}
|
||||
|
||||
function countdownLabel(value) {
|
||||
const days = daysUntil(value);
|
||||
if (days === null) return "";
|
||||
if (days === 0) return "today";
|
||||
if (days === 1) return "tomorrow";
|
||||
if (days === -1) return "yesterday";
|
||||
if (days > 1) return `in ${days} days`;
|
||||
return `${-days} days ago`;
|
||||
}
|
||||
|
||||
function countdownClass(value) {
|
||||
const days = daysUntil(value);
|
||||
if (days === 0) return "now";
|
||||
if (days !== null && days > 0 && days <= 7) return "soon";
|
||||
return "";
|
||||
}
|
||||
|
||||
function shortTime(value) {
|
||||
if (!value) return "";
|
||||
const match = String(value).match(/^(\d{1,2}):(\d{2})/);
|
||||
if (!match) return String(value);
|
||||
return `${match[1].padStart(2, "0")}:${match[2]}`;
|
||||
}
|
||||
|
||||
function timeRange(from, to) {
|
||||
const start = shortTime(from);
|
||||
const end = shortTime(to);
|
||||
if (start && end) return `${start}–${end}`;
|
||||
return start || end || "—";
|
||||
}
|
||||
|
||||
function timeToMinutes(value) {
|
||||
const match = String(value || "").match(/^(\d{1,2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function truncate(value, length = 90) {
|
||||
const text = String(value ?? "");
|
||||
if (text.length <= length) return text;
|
||||
return `${text.slice(0, Math.max(0, length - 1))}…`;
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
const text = String(value ?? "");
|
||||
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
|
||||
}
|
||||
|
||||
function weightNumber(weight) {
|
||||
const number = Number.parseFloat(String(weight ?? "").replace(",", "."));
|
||||
return Number.isFinite(number) && number > 0 ? number : 1;
|
||||
}
|
||||
|
||||
function numericGrade(grade) {
|
||||
const number = Number.parseFloat(String(grade ?? "").replace(",", "."));
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function gradeClass(grade) {
|
||||
const number = numericGrade(grade);
|
||||
if (number === null) return "g-none";
|
||||
if (number >= 5) return "g-excellent";
|
||||
if (number >= 4) return "g-good";
|
||||
if (number >= 3) return "g-ok";
|
||||
return "g-bad";
|
||||
}
|
||||
|
||||
function subjectAverage(grades) {
|
||||
let total = 0;
|
||||
let weights = 0;
|
||||
for (const grade of grades) {
|
||||
const value = numericGrade(grade.Grade);
|
||||
if (value === null) continue;
|
||||
const weight = weightNumber(grade.Weight);
|
||||
total += value * weight;
|
||||
weights += weight;
|
||||
}
|
||||
return weights ? total / weights : null;
|
||||
}
|
||||
|
||||
function averageLabel(grades) {
|
||||
const average = subjectAverage(grades);
|
||||
return average === null ? "—" : average.toFixed(2);
|
||||
}
|
||||
|
||||
function examTagClass(type) {
|
||||
const text = String(type || "").toLowerCase();
|
||||
if (text.includes("sprawdzian") || text.includes("test")) return "tag-red";
|
||||
if (text.includes("kartk") || text.includes("quiz")) return "tag-amber";
|
||||
if (text.includes("projekt") || text.includes("wyprac") || text.includes("esej")) return "tag-cyan";
|
||||
return "tag-dim";
|
||||
}
|
||||
|
||||
function schoolAddress(school) {
|
||||
if (!school) return "—";
|
||||
const parts = [school.Street, school.BuildingNumber].filter(Boolean).join(" ");
|
||||
const city = [school.PostCode, school.Town].filter(Boolean).join(" ");
|
||||
return [parts, city].filter(Boolean).join(", ") || school.Name || "—";
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- API */
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status = 0) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, opts);
|
||||
} catch (error) {
|
||||
throw new ApiError("Network error: cannot reach the librus-tui server.", 0);
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (
|
||||
response.status === 401 &&
|
||||
state.session &&
|
||||
state.session.authenticated &&
|
||||
!path.startsWith("/api/login")
|
||||
) {
|
||||
handleExpiredSession();
|
||||
}
|
||||
const message = (data && data.error) || `Request failed (HTTP ${response.status}).`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ chrome helpers */
|
||||
|
||||
function setLed(kind) {
|
||||
const led = $("#connection-led");
|
||||
if (!led) return;
|
||||
led.classList.remove("online", "busy", "offline");
|
||||
if (kind) led.classList.add(kind);
|
||||
}
|
||||
|
||||
function setStatus(text, kind = "") {
|
||||
const element = $("#status-left");
|
||||
if (!element) return;
|
||||
element.textContent = text;
|
||||
element.className = `status-left${kind ? ` ${kind}` : ""}`;
|
||||
}
|
||||
|
||||
function setMeta(text) {
|
||||
const element = $("#view-meta");
|
||||
if (element) element.textContent = text;
|
||||
}
|
||||
|
||||
function syncTime() {
|
||||
return new Date().toLocaleTimeString("en-GB", { hour12: false });
|
||||
}
|
||||
|
||||
function setViewReady(note) {
|
||||
stopSpinner();
|
||||
setLed("online");
|
||||
state.lastSync = new Date();
|
||||
const label = note || `loaded ${VIEW_TITLES[state.view] || state.view}`;
|
||||
setMeta(`synced ${syncTime()}`);
|
||||
setStatus(`[ok] ${label}`, "ok");
|
||||
const statusRight = $("#status-right");
|
||||
if (statusRight) statusRight.textContent = "synergia://connected";
|
||||
}
|
||||
|
||||
function startSpinner() {
|
||||
stopSpinner();
|
||||
const frames = "|/-\\";
|
||||
let index = 0;
|
||||
spinnerTimer = setInterval(() => {
|
||||
$$(".spinner").forEach((element) => {
|
||||
element.textContent = frames[index % frames.length];
|
||||
});
|
||||
index += 1;
|
||||
}, 110);
|
||||
}
|
||||
|
||||
function stopSpinner() {
|
||||
if (spinnerTimer) {
|
||||
clearInterval(spinnerTimer);
|
||||
spinnerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setViewLoading(label) {
|
||||
const view = $("#view");
|
||||
if (!view) return;
|
||||
view.innerHTML = `<div class="loading"><span class="spinner">|</span> ${esc(label)}</div>`;
|
||||
setMeta("contacting synergia…");
|
||||
setStatus(`[...] ${label}`);
|
||||
setLed("busy");
|
||||
startSpinner();
|
||||
}
|
||||
|
||||
function toast(message, kind = "ok", duration = 3800) {
|
||||
const root = $("#toasts");
|
||||
if (!root) return;
|
||||
const element = document.createElement("div");
|
||||
element.className = `toast ${kind}`;
|
||||
const prefix = document.createElement("span");
|
||||
prefix.className = "toast-prefix";
|
||||
prefix.textContent = kind === "err" ? "!!" : kind === "warn" ? "!" : ">>";
|
||||
element.appendChild(prefix);
|
||||
element.appendChild(document.createTextNode(message));
|
||||
root.appendChild(element);
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- modal */
|
||||
|
||||
function openModal(title, bodyHtml, actions = []) {
|
||||
const root = $("#modal-root");
|
||||
const titleElement = $("#modal-title");
|
||||
const bodyElement = $("#modal-body");
|
||||
const actionsElement = $("#modal-actions");
|
||||
if (!root || !titleElement || !bodyElement || !actionsElement) return;
|
||||
|
||||
titleElement.textContent = title;
|
||||
bodyElement.innerHTML = bodyHtml;
|
||||
actionsElement.innerHTML = "";
|
||||
for (const action of actions) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn${action.kind ? ` ${action.kind}` : ""}`;
|
||||
button.textContent = action.label;
|
||||
if (action.close) {
|
||||
button.dataset.modalClose = "";
|
||||
} else if (action.action) {
|
||||
button.dataset.action = action.action;
|
||||
}
|
||||
actionsElement.appendChild(button);
|
||||
}
|
||||
root.classList.remove("hidden");
|
||||
const box = $("#modal-box");
|
||||
if (box) box.focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const root = $("#modal-root");
|
||||
if (root) root.classList.add("hidden");
|
||||
}
|
||||
|
||||
function openHelpModal() {
|
||||
const body = `
|
||||
<p><b>librus-tui</b> is a keyboard-first terminal frontend for the Librus Synergia
|
||||
student portal. Everything is rendered from the Synergia gateway API.</p>
|
||||
<div class="help-section">global keys</div>
|
||||
<dl class="help-grid">
|
||||
<dt>1 … 7</dt><dd>jump to a section</dd>
|
||||
<dt>r</dt><dd>refresh the current section</dd>
|
||||
<dt>:</dt><dd>open the command line</dd>
|
||||
<dt>?</dt><dd>this help window</dd>
|
||||
<dt>q</dt><dd>log out</dd>
|
||||
<dt>j / k</dt><dd>scroll, or move through the inbox</dd>
|
||||
<dt>g / G</dt><dd>jump to top / bottom</dd>
|
||||
</dl>
|
||||
<div class="help-section">sections</div>
|
||||
<dl class="help-grid">
|
||||
<dt>grades</dt><dd><kbd>/</kbd> filter · click a grade for details</dd>
|
||||
<dt>timetable</dt><dd><kbd>[</kbd> previous week · <kbd>]</kbd> next week</dd>
|
||||
<dt>homework</dt><dd>click a row for the full description</dd>
|
||||
<dt>messages</dt><dd><kbd>j</kbd>/<kbd>k</kbd> select · <kbd>Enter</kbd> open · attachments download</dd>
|
||||
</dl>
|
||||
<div class="help-section">commands</div>
|
||||
<dl class="help-grid">
|
||||
<dt>:dashboard :grades …</dt><dd>jump to any section (aliases: d, g, t, h, a, m, f)</dd>
|
||||
<dt>:week 0..3</dt><dd>select a timetable week</dd>
|
||||
<dt>:reload</dt><dd>refresh the current section</dd>
|
||||
<dt>:status</dt><dd>print session information</dd>
|
||||
<dt>:clear</dt><dd>clear the screen</dd>
|
||||
<dt>:fullscreen / :fs</dt><dd>toggle browser fullscreen</dd>
|
||||
<dt>:logout</dt><dd>end the Synergia session</dd>
|
||||
</dl>
|
||||
<div class="help-section">about</div>
|
||||
<p>Synergia client adapted from
|
||||
<a href="https://github.com/dani3l0/librusik" target="_blank" rel="noopener noreferrer">dani3l0/librusik</a>
|
||||
(<code>lib/api/librus.py</code>, stable).<br>
|
||||
Your credentials are exchanged directly with Librus and are never written to disk.</p>
|
||||
`;
|
||||
openModal("help", body, [{ label: "[ close ]", close: true }]);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- boot log */
|
||||
|
||||
function appendBoot(kind, text) {
|
||||
const log = $("#bootlog");
|
||||
if (!log) return;
|
||||
const line = document.createElement("span");
|
||||
if (kind) line.className = kind;
|
||||
line.textContent = `${text}\n`;
|
||||
log.appendChild(line);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function clearBoot() {
|
||||
const log = $("#bootlog");
|
||||
if (log) log.innerHTML = "";
|
||||
}
|
||||
|
||||
function stopBootTimer() {
|
||||
if (bootTimer) {
|
||||
clearInterval(bootTimer);
|
||||
bootTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startBootTimer() {
|
||||
stopBootTimer();
|
||||
const lines = [
|
||||
["info", " $ ssh synergia.librus.pl"],
|
||||
["cmd", " GET /loguj/portalRodzina"],
|
||||
["cmd", " POST api.librus.pl/OAuth/Authorization?client_id=46"],
|
||||
["cmd", " GET /OAuth/Authorization/2FA?client_id=46"],
|
||||
["cmd", " waiting for DZIENNIKSID / SDZIENNIKSID …"],
|
||||
["cmd", " probing /gateway/api/2.0/Auth/TokenInfo …"],
|
||||
];
|
||||
let index = 0;
|
||||
bootTimer = setInterval(() => {
|
||||
if (index < lines.length) {
|
||||
appendBoot(lines[index][0], lines[index][1]);
|
||||
index += 1;
|
||||
} else {
|
||||
stopBootTimer();
|
||||
}
|
||||
}, 360);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
}
|
||||
@@ -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" : ""}>[ < prev ]</button>
|
||||
<button class="btn btn-sm" type="button" data-action="tt-next" ${state.timetableWeek >= 3 ? "disabled" : ""}>[ next > ]</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");
|
||||
}
|
||||
@@ -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 & 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>[↓]</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>
|
||||
`;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user