Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
490
src/http/app/app.js
Normal file
490
src/http/app/app.js
Normal file
@@ -0,0 +1,490 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* The notes app.
|
||||
*
|
||||
* One school day is one note, one lesson is one `##` heading, and the server
|
||||
* builds the headings from WebUntis — so opening the app during a free period
|
||||
* gives you the day already laid out rather than an empty box. That shape is
|
||||
* also what makes each lesson separately searchable afterwards, which is the
|
||||
* whole reason the notes are worth writing here rather than in Notes.app.
|
||||
*
|
||||
* Three rules this file exists to honour:
|
||||
*
|
||||
* - **Never lose what was typed.** Every keystroke goes to localStorage, and a
|
||||
* draft that is newer than the server's copy survives a dead connection, a
|
||||
* locked phone and a closed tab. A note taken in a lesson cannot be retaken.
|
||||
* - **Never silently overwrite.** Saves carry the modification time the editor
|
||||
* loaded; the server refuses one that would clobber a version this editor
|
||||
* never saw, and the banner then makes it the person's decision.
|
||||
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
||||
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
||||
* indistinguishable from one that is losing your work.
|
||||
*/
|
||||
|
||||
const AUTOSAVE_MS = 2500;
|
||||
const DRAFT_PREFIX = 'schulcloud-mcp/draft/';
|
||||
|
||||
const ui = {
|
||||
login: document.getElementById('login'),
|
||||
loginForm: document.getElementById('login-form'),
|
||||
password: document.getElementById('password'),
|
||||
loginError: document.getElementById('login-error'),
|
||||
app: document.getElementById('app'),
|
||||
tabNotes: document.getElementById('tab-notes'),
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
viewNotes: document.getElementById('view-notes'),
|
||||
viewSettings: document.getElementById('view-settings'),
|
||||
prev: document.getElementById('prev'),
|
||||
next: document.getElementById('next'),
|
||||
dayTitle: document.getElementById('day-title'),
|
||||
dayDate: document.getElementById('day-date'),
|
||||
dayStatus: document.getElementById('day-status'),
|
||||
conflict: document.getElementById('day-conflict'),
|
||||
editor: document.getElementById('editor'),
|
||||
save: document.getElementById('save'),
|
||||
fill: document.getElementById('fill'),
|
||||
lessonsHint: document.getElementById('lessons-hint'),
|
||||
tokenState: document.getElementById('token-state'),
|
||||
tokenForm: document.getElementById('token-form'),
|
||||
jwt: document.getElementById('jwt'),
|
||||
tokenResult: document.getElementById('token-result'),
|
||||
serverState: document.getElementById('server-state'),
|
||||
logout: document.getElementById('logout'),
|
||||
};
|
||||
|
||||
/** Everything about the day currently open. */
|
||||
const day = {
|
||||
date: today(),
|
||||
path: '',
|
||||
/** The server's modification time for the loaded note, or null if there is none. */
|
||||
modifiedAt: null,
|
||||
/** The text as the server last confirmed it, to tell "dirty" from "saved". */
|
||||
saved: '',
|
||||
/** Headings the timetable has and the note does not. */
|
||||
missing: '',
|
||||
dirty: false,
|
||||
conflicted: false,
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
// --- plumbing ------------------------------------------------------------
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: { accept: 'application/json', ...(options && options.headers) },
|
||||
});
|
||||
if (response.status === 401) {
|
||||
showLogin();
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
body = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status);
|
||||
failure.status = response.status;
|
||||
failure.body = body;
|
||||
throw failure;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function json(method, path, payload) {
|
||||
return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
function today() {
|
||||
// The device's own date. The server keeps school dates in Europe/Berlin, but
|
||||
// the phone in the lesson is in that timezone by definition.
|
||||
const now = new Date();
|
||||
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-');
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function shiftDate(date, days) {
|
||||
// Noon, so a daylight-saving change cannot push the result onto the
|
||||
// neighbouring day.
|
||||
const at = new Date(date + 'T12:00:00');
|
||||
at.setDate(at.getDate() + days);
|
||||
return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-');
|
||||
}
|
||||
|
||||
function clock() {
|
||||
const now = new Date();
|
||||
return pad(now.getHours()) + ':' + pad(now.getMinutes());
|
||||
}
|
||||
|
||||
// --- drafts: the safety net ---------------------------------------------
|
||||
|
||||
function draftKey(date) {
|
||||
return DRAFT_PREFIX + date;
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
try {
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() }));
|
||||
} catch (error) {
|
||||
// A full or disabled localStorage must not break typing; the server copy
|
||||
// is still the real one.
|
||||
}
|
||||
}
|
||||
|
||||
function readDraft(date) {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(date));
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(date) {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(date));
|
||||
} catch (error) {
|
||||
// Nothing to do: a stale draft is only ever offered, never forced.
|
||||
}
|
||||
}
|
||||
|
||||
// --- the day -------------------------------------------------------------
|
||||
|
||||
function setStatus(message, kind) {
|
||||
ui.dayStatus.textContent = message;
|
||||
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
async function loadDay(date) {
|
||||
// Anything unsaved goes to the draft before the view moves, or switching
|
||||
// days would be a way to lose a lesson.
|
||||
if (day.dirty) saveDraft();
|
||||
window.clearTimeout(day.timer);
|
||||
|
||||
day.date = date;
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
ui.dayDate.value = date;
|
||||
ui.editor.disabled = true;
|
||||
setStatus('Wird geladen …');
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await api('/api/notes/day?date=' + encodeURIComponent(date));
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.dayTitle.textContent = date;
|
||||
// A reply with a status is the server saying no — most often that it keeps
|
||||
// no notes at all — and reporting that as "offline" would send someone
|
||||
// looking at their signal instead of at NOTES_DIR.
|
||||
if (error.status) {
|
||||
ui.editor.value = '';
|
||||
ui.editor.disabled = true;
|
||||
setStatus(error.message, 'error');
|
||||
ui.lessonsHint.textContent = '';
|
||||
return;
|
||||
}
|
||||
// No status: the request never arrived. Fall back to whatever this device
|
||||
// has, rather than an empty editor that looks like a day with no notes.
|
||||
const draft = readDraft(date);
|
||||
ui.editor.disabled = false;
|
||||
ui.editor.value = draft ? draft.text : '';
|
||||
day.saved = '';
|
||||
day.modifiedAt = null;
|
||||
day.dirty = Boolean(draft);
|
||||
setStatus(
|
||||
draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.',
|
||||
'warn',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
day.path = info.path;
|
||||
day.modifiedAt = info.modifiedAt;
|
||||
day.missing = info.missing || '';
|
||||
ui.dayTitle.textContent = info.title;
|
||||
|
||||
const server = info.exists ? info.text : info.skeleton;
|
||||
const draft = readDraft(date);
|
||||
// A draft only wins when it differs from what the server holds; otherwise it
|
||||
// is just the last save echoed back and offering it would be noise.
|
||||
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
||||
|
||||
ui.editor.value = useDraft ? draft.text : server;
|
||||
ui.editor.disabled = false;
|
||||
day.saved = info.exists ? info.text : '';
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
|
||||
if (useDraft) {
|
||||
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
||||
} else if (info.exists) {
|
||||
setStatus('Gespeichert.');
|
||||
} else if (info.skeleton) {
|
||||
setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.');
|
||||
} else {
|
||||
setStatus('Neuer Tag.');
|
||||
}
|
||||
|
||||
describeLessons(info);
|
||||
ui.fill.hidden = !day.missing;
|
||||
}
|
||||
|
||||
function describeLessons(info) {
|
||||
if (info.timetable === 'off') {
|
||||
ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.';
|
||||
return;
|
||||
}
|
||||
if (info.timetable === 'unavailable') {
|
||||
ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.';
|
||||
return;
|
||||
}
|
||||
const count = (info.lessons || []).length;
|
||||
ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.';
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
saveDraft();
|
||||
if (day.conflicted) return;
|
||||
if (day.dirty) setStatus('Nicht gespeichert …');
|
||||
window.clearTimeout(day.timer);
|
||||
day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
async function saveDay(automatic) {
|
||||
window.clearTimeout(day.timer);
|
||||
if (!day.dirty && automatic) return;
|
||||
const text = ui.editor.value;
|
||||
setStatus('Wird gespeichert …');
|
||||
|
||||
try {
|
||||
const result = await json('PUT', '/api/notes/day', {
|
||||
date: day.date,
|
||||
text,
|
||||
// Absent for a note that does not exist yet: there is nothing to clash
|
||||
// with, and sending null would look like "I saw no version".
|
||||
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
|
||||
});
|
||||
day.saved = text;
|
||||
day.modifiedAt = result.modifiedAt;
|
||||
day.dirty = false;
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
clearDraft(day.date);
|
||||
setStatus('Gespeichert ' + clock() + '.', 'ok');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
if (error.status === 409) {
|
||||
// Stop autosaving: every further attempt would fail the same way, and
|
||||
// the choice of which version wins is not ours to make.
|
||||
day.conflicted = true;
|
||||
ui.conflict.hidden = false;
|
||||
ui.conflict.textContent =
|
||||
'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' +
|
||||
'„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' +
|
||||
'Deine Fassung ist lokal gesichert.';
|
||||
ensureConflictButtons();
|
||||
setStatus('Nicht gespeichert — Konflikt.', 'error');
|
||||
return;
|
||||
}
|
||||
if (error.status === 403) {
|
||||
setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error');
|
||||
return;
|
||||
}
|
||||
setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** The two ways out of a conflict, added once and only when one happens. */
|
||||
function ensureConflictButtons() {
|
||||
if (document.getElementById('conflict-reload')) return;
|
||||
const reload = document.createElement('button');
|
||||
reload.id = 'conflict-reload';
|
||||
reload.type = 'button';
|
||||
reload.textContent = 'Neu laden';
|
||||
reload.addEventListener('click', () => {
|
||||
clearDraft(day.date);
|
||||
void loadDay(day.date);
|
||||
});
|
||||
|
||||
const force = document.createElement('button');
|
||||
force.id = 'conflict-force';
|
||||
force.type = 'button';
|
||||
force.textContent = 'Trotzdem speichern';
|
||||
force.addEventListener('click', () => {
|
||||
day.modifiedAt = null;
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
void saveDay(false);
|
||||
});
|
||||
|
||||
ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force);
|
||||
}
|
||||
|
||||
// --- settings ------------------------------------------------------------
|
||||
|
||||
async function loadSettings() {
|
||||
ui.tokenState.textContent = 'Wird geladen …';
|
||||
try {
|
||||
const info = await api('/api/token');
|
||||
const budget = info.keepalive && info.keepalive.budgetSeconds;
|
||||
ui.tokenState.textContent =
|
||||
'Noch ' + info.daysLeft + ' Tag(e) gültig' +
|
||||
(budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') +
|
||||
' (' + info.source + ').';
|
||||
ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : '');
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message;
|
||||
ui.tokenState.className = 'status error';
|
||||
}
|
||||
|
||||
ui.serverState.replaceChildren();
|
||||
try {
|
||||
const status = await api('/api/status');
|
||||
addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer');
|
||||
addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)');
|
||||
if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft');
|
||||
} catch (error) {
|
||||
addFact('Index', 'nicht verfügbar');
|
||||
}
|
||||
try {
|
||||
const notes = await api('/api/notes?limit=1');
|
||||
addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)'));
|
||||
} catch (error) {
|
||||
addFact('Notizen', 'nicht verfügbar');
|
||||
}
|
||||
}
|
||||
|
||||
function addFact(term, value) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = term;
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = value;
|
||||
ui.serverState.append(dt, dd);
|
||||
}
|
||||
|
||||
// --- views ---------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
ui.app.hidden = true;
|
||||
ui.login.hidden = false;
|
||||
ui.password.focus();
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
ui.login.hidden = true;
|
||||
ui.app.hidden = false;
|
||||
}
|
||||
|
||||
function showTab(name) {
|
||||
const notes = name !== 'settings';
|
||||
ui.viewNotes.hidden = !notes;
|
||||
ui.viewSettings.hidden = notes;
|
||||
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
|
||||
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
|
||||
if (!notes) void loadSettings();
|
||||
}
|
||||
|
||||
// --- wiring --------------------------------------------------------------
|
||||
|
||||
ui.loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
ui.loginError.textContent = '';
|
||||
try {
|
||||
await json('POST', '/app/login', { password: ui.password.value });
|
||||
ui.password.value = '';
|
||||
showApp();
|
||||
await loadDay(day.date);
|
||||
} catch (error) {
|
||||
ui.loginError.textContent =
|
||||
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
|
||||
}
|
||||
});
|
||||
|
||||
ui.logout.addEventListener('click', async () => {
|
||||
// The draft stays: logging out is not the same as discarding a lesson.
|
||||
await json('POST', '/app/logout', {}).catch(() => {});
|
||||
showLogin();
|
||||
});
|
||||
|
||||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||||
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
||||
|
||||
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||
ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.editor.addEventListener('input', markDirty);
|
||||
ui.save.addEventListener('click', () => void saveDay(false));
|
||||
|
||||
ui.fill.addEventListener('click', () => {
|
||||
// Appended, never merged into place: the person's own text is not something
|
||||
// to reorder, and a heading in the wrong order is trivial to move.
|
||||
const separator = ui.editor.value.trim() ? '\n\n' : '';
|
||||
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
|
||||
day.missing = '';
|
||||
ui.fill.hidden = true;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// A phone locking, the app going to the background, or the tab closing: all of
|
||||
// them end the session without a "save" ever being pressed.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden' && day.dirty) {
|
||||
saveDraft();
|
||||
if (!day.conflicted) void saveDay(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!day.dirty) return;
|
||||
saveDraft();
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
|
||||
ui.tokenForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
ui.tokenResult.textContent = 'Wird geprüft …';
|
||||
ui.tokenResult.className = 'status';
|
||||
try {
|
||||
const result = await json('PUT', '/api/token', { jwt: ui.jwt.value });
|
||||
ui.jwt.value = '';
|
||||
ui.tokenResult.textContent = result.changed
|
||||
? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)')
|
||||
: 'Das ist der Token, der bereits benutzt wird.';
|
||||
ui.tokenResult.className = 'status ok';
|
||||
void loadSettings();
|
||||
} catch (error) {
|
||||
if (error.message === 'unauthorized') return;
|
||||
ui.tokenResult.textContent = error.message;
|
||||
ui.tokenResult.className = 'status error';
|
||||
}
|
||||
});
|
||||
|
||||
// --- start ---------------------------------------------------------------
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await api('/app/session');
|
||||
if (!session.authenticated) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
showApp();
|
||||
await loadDay(day.date);
|
||||
})();
|
||||
Reference in New Issue
Block a user