From bbf92bb4782447d4b805baca2f5ca19a08657fb2 Mon Sep 17 00:00:00 2001 From: ~purplebored Date: Sun, 8 Nov 2026 19:38:17 +0100 Subject: [PATCH] Add AMOLED theme, fullscreen, settings tab, and NTFY notifications --- app.py | 54 ++++++- static/index.html | 3 + static/js/00-core.js | 11 +- static/js/10-auth.js | 4 + static/js/26-more-views.js | 319 +++++++++++++++++++++++++++++++++++++ static/js/30-app.js | 42 ++++- 6 files changed, 429 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 4de248c..59ae6f4 100644 --- a/app.py +++ b/app.py @@ -17,8 +17,9 @@ import secrets import time from dataclasses import dataclass, field from pathlib import Path -from urllib.parse import urlparse +from urllib.parse import quote, urlparse +import aiohttp from aiohttp import web from bs4 import BeautifulSoup, Comment @@ -421,6 +422,56 @@ async def api_conferences(request): return json_response({"conferences": data or []}) +async def api_ntfy(request): + """Proxy a notification to an NTFY server (avoids browser CORS/CSP limits).""" + get_session(request) # require an authenticated session + try: + body = await request.json() + except Exception: + raise ApiError(400, "Expected a JSON object.") + if not isinstance(body, dict): + raise ApiError(400, "Expected a JSON object.") + + server = str(body.get("server") or "").strip() + topic = str(body.get("topic") or "").strip() + token = str(body.get("token") or "").strip() + title = str(body.get("title") or "librus-tui").strip()[:120] + message = str(body.get("message") or "").strip()[:4000] + priority = body.get("priority") + + if not server or not topic: + raise ApiError(400, "NTFY server and topic are required.") + parsed = urlparse(server) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ApiError(400, "Invalid NTFY server URL.") + host = (parsed.hostname or "").lower() + if host in ("localhost", "127.0.0.1", "::1") or host.startswith("169.254."): + raise ApiError(400, "NTFY server host is not allowed.") + + url = server.rstrip("/") + "/" + quote(topic, safe="") + headers = { + "Title": title or "librus-tui", + "Content-Type": "text/plain; charset=utf-8", + } + if token: + headers["Authorization"] = f"Bearer {token}" + if priority is not None: + headers["Priority"] = str(priority) + + timeout = aiohttp.ClientTimeout(total=15) + try: + async with aiohttp.ClientSession(timeout=timeout) as client: + async with client.post(url, headers=headers, data=message.encode("utf-8")) as response: + text = await response.text() + if response.status >= 400: + raise ApiError(502, f"NTFY returned {response.status}: {text[:200].strip()}") + return json_response({"ok": True, "status": response.status}) + except ApiError: + raise + except Exception as exc: + raise ApiError(502, f"NTFY request failed: {exc}") + + # --------------------------------------------------------------------------- static @@ -557,6 +608,7 @@ def create_app(demo=False): 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_post("/api/ntfy", api_ntfy) app.router.add_get("/healthz", healthz) app.router.add_get("/", index) diff --git a/static/index.html b/static/index.html index 270b277..1c47b57 100644 --- a/static/index.html +++ b/static/index.html @@ -105,6 +105,9 @@ +
+ + + +
+ + + +
+
notification categorieswhat to push
+
+ + + + +

+ Polling runs while this tab is open. The first poll only records the + baseline and does not send pushes. +

+
+
+ +
+
activity logrecent pushes
+
+
+ + + `; + fillSettingsForm(settings); + renderSettingsLog($("#settings-log")); + const status = $("#settings-status"); + if (status) { + status.textContent = settings.enabled && settings.topic + ? `enabled · topic ${settings.topic} · poll every ${settings.pollMinutes} min` + : "disabled · configure a topic to enable pushes"; + } +} + +function stopNotificationPolling() { + if (state.notificationPoller) { + clearInterval(state.notificationPoller); + state.notificationPoller = null; + } +} + +function startNotificationPolling() { + stopNotificationPolling(); + const settings = loadSettings(); + state.lastNotifySnapshot = null; + if (!settings.enabled) return; + const minutes = Math.max(1, Number(settings.pollMinutes) || 5); + pollNotifications(); + state.notificationPoller = setInterval(pollNotifications, minutes * 60 * 1000); +} + +async function pollNotifications() { + if (!state.session || !state.session.authenticated) return; + const settings = loadSettings(); + if (!settings.enabled) return; + try { + const notif = await api("/api/notifications"); + let messageCount = 0; + if (settings.messages) { + try { + const messages = await api("/api/messages"); + messageCount = Array.isArray(messages) + ? messages.length + : Array.isArray(messages?.items) + ? messages.items.length + : 0; + } catch (error) { + messageCount = state.lastNotifySnapshot?.messages || 0; + } + } else if (state.lastNotifySnapshot) { + messageCount = state.lastNotifySnapshot.messages; + } + + const snapshot = { + grades: Number(notif?.grades) || 0, + exams: Number(notif?.exams) || 0, + absences: Number(notif?.absences) || 0, + conferences: Number(notif?.conferences) || 0, + messages: messageCount, + }; + + if (state.lastNotifySnapshot) { + const previous = state.lastNotifySnapshot; + const pushes = []; + if (settings.grades && snapshot.grades > previous.grades) { + pushes.push({ + title: "New grades", + message: `${snapshot.grades - previous.grades} new grade(s). Total: ${snapshot.grades}.`, + priority: 3, + }); + } + if (settings.exams && snapshot.exams > previous.exams) { + pushes.push({ + title: "New exams / homework", + message: `${snapshot.exams - previous.exams} new item(s). Total: ${snapshot.exams}.`, + priority: 4, + }); + } + if (settings.messages && snapshot.messages > previous.messages) { + pushes.push({ + title: "New messages", + message: `${snapshot.messages - previous.messages} new message(s). Total: ${snapshot.messages}.`, + priority: 3, + }); + } + if (settings.attendance && snapshot.absences > previous.absences) { + pushes.push({ + title: "New absence", + message: `${snapshot.absences - previous.absences} new absence entry. Total: ${snapshot.absences}.`, + priority: 4, + }); + } + + for (const push of pushes) { + toast(`[ntfy] ${push.title}`, "ok", 2200); + try { + await sendNtfy(settings, push.title, push.message, push.priority); + appendSettingsLog(`pushed: ${push.title}`); + } catch (error) { + appendSettingsLog(`push failed: ${push.title} — ${error.message}`); + toast(`[ntfy fail] ${error.message}`, "warn"); + } + } + } + + state.lastNotifySnapshot = snapshot; + } catch (error) { + /* polling failures are non-fatal */ + } +} diff --git a/static/js/30-app.js b/static/js/30-app.js index b773471..706470b 100644 --- a/static/js/30-app.js +++ b/static/js/30-app.js @@ -55,6 +55,9 @@ async function loadView(view) { case "freedays": await renderFreedays(token); break; + case "settings": + await renderSettings(token); + break; case "dashboard": default: await renderDashboard(token); @@ -212,7 +215,7 @@ function runCommand(raw) { /* ------------------------------------------------------------------- actions */ -function handleAction(action) { +async function handleAction(action) { switch (action) { case "refresh": refreshCurrent(); @@ -245,6 +248,43 @@ function handleAction(action) { case "retry": loadView(state.view); break; + case "settings-save": { + const next = collectSettingsFromForm(); + saveSettings(next); + const status = $("#settings-status"); + if (status) { + status.textContent = next.enabled && next.topic + ? `saved · topic ${next.topic} · poll every ${next.pollMinutes} min` + : "saved · disabled (no topic)"; + } + appendSettingsLog(next.enabled ? "settings saved · ntfy enabled" : "settings saved · ntfy disabled"); + toast(next.enabled ? "Settings saved. NTFY enabled." : "Settings saved.", "ok", 1800); + startNotificationPolling(); + break; + } + case "settings-test": { + const current = collectSettingsFromForm(); + const status = $("#settings-status"); + try { + await sendNtfy(current, "librus-tui test", "Test notification from librus-tui settings."); + if (status) status.textContent = "test push sent"; + appendSettingsLog("test push sent"); + toast("Test notification sent.", "ok", 1800); + } catch (error) { + if (status) status.textContent = `test failed: ${error.message}`; + appendSettingsLog(`test push failed: ${error.message}`); + toast(`[ntfy fail] ${error.message}`, "warn"); + } + break; + } + case "settings-clear": { + const defaults = defaultSettings(); + saveSettings(defaults); + renderSettings(); + startNotificationPolling(); + toast("Settings reset to defaults.", "ok", 1800); + break; + } default: break; }