Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
type FsErrorCode,
|
||||
type WalkEntry,
|
||||
} from '../core/legacy-files.ts';
|
||||
import { filterNotes, NoteNotFound, readNoteAt, readNotes, writeNote } from '../core/notes.ts';
|
||||
import { resolveWithin } from '../core/paths.ts';
|
||||
import { TokenRejected } from '../core/session-token.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
@@ -28,6 +29,9 @@ import type { Services } from '../services.ts';
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
const NO_NOTES_DIR =
|
||||
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
|
||||
|
||||
export function createApiRouter(services: Services): Router {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -237,6 +241,72 @@ export function createApiRouter(services: Services): Router {
|
||||
// The only upstream call is the GET /me a replacement must pass first. Works
|
||||
// without an index, since a server without one still needs a token.
|
||||
|
||||
// --- the user's own lesson notes ----------------------------------------
|
||||
//
|
||||
// Read off disk, like the fs_* routes read Schulcloud: no index involved, so
|
||||
// these answer before the first crawl and while Postgres is down. The POST is
|
||||
// the only write in this server that is not the index or its own token, and
|
||||
// it can reach nothing but the notes directory — `writeNote` builds every
|
||||
// path component with `safeComponent` and checks the result with
|
||||
// `resolveWithin`.
|
||||
|
||||
router.get('/notes', async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
try {
|
||||
const path = stringParam(req.query.path);
|
||||
if (path) return res.json(await readNoteAt(root, path));
|
||||
const notes = filterNotes(await readNotes(root), {
|
||||
...pickParam('subject', req.query.subject),
|
||||
...pickParam('since', req.query.since),
|
||||
...pickParam('until', req.query.until),
|
||||
...pickParam('courseId', req.query.courseId),
|
||||
});
|
||||
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000);
|
||||
return res.json({
|
||||
root,
|
||||
writable: services.config.notesWritable,
|
||||
count: notes.length,
|
||||
// The body is dropped from a listing: a term of notes is megabytes,
|
||||
// and the CLI asks for the ones it wants by path.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
||||
return fail(res, error, 'notes');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
if (!services.config.notesWritable) {
|
||||
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const noteText = typeof body.text === 'string' ? body.text : '';
|
||||
if (!title || !noteText.trim()) {
|
||||
return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' });
|
||||
}
|
||||
try {
|
||||
const { note, appended } = await writeNote(root, {
|
||||
title,
|
||||
text: noteText,
|
||||
...pickParam('date', body.date),
|
||||
...pickParam('subject', body.subject),
|
||||
...pickParam('courseId', body.courseId),
|
||||
...pickParam('path', body.path),
|
||||
...pickParam('source', body.source),
|
||||
...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}),
|
||||
append: body.append === true,
|
||||
});
|
||||
return res.status(appended ? 200 : 201).json({ ...note, appended });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'save a note');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/token', (_req: Request, res: Response) => {
|
||||
res.json(tokenStatus(services));
|
||||
});
|
||||
@@ -352,6 +422,16 @@ function stringParam(value: unknown): string | undefined {
|
||||
return typeof first === 'string' && first.length > 0 ? first : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query or body value as an optional field, so callers can spread it into an
|
||||
* options object without turning "not given" into `undefined` the way an
|
||||
* exactOptionalPropertyTypes build rejects.
|
||||
*/
|
||||
function pickParam<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
|
||||
const text = stringParam(value);
|
||||
return text ? ({ [key]: text } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(stringParam(value) ?? '', 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
|
||||
Reference in New Issue
Block a user