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:
@@ -9,6 +9,7 @@ import { readHidden, readPiped } from '../cli/prompt.ts';
|
||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||
import { noteAdd, noteImport, noteList, noteShow } from '../cli/notes.ts';
|
||||
import { sync, type SyncEvent } from '../cli/sync.ts';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,17 @@ The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||
fs get downloads a file, or a folder with everything below it. Names may contain
|
||||
"/" and still resolve; any path segment can also be an id from "fs ls --long".
|
||||
|
||||
Your own lesson notes — Markdown files the agents read as context:
|
||||
|
||||
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
||||
schulcloud note show <path>
|
||||
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
||||
[--tags a,b] [--append] text on stdin, or --text
|
||||
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
||||
|
||||
note import takes the file scripts/export-apple-notes.js writes on a Mac; see
|
||||
docs/NOTES.md. --out writes the Markdown locally instead of sending it.
|
||||
|
||||
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
|
||||
and their files sit under the room's name.
|
||||
|
||||
@@ -74,6 +86,9 @@ async function main(argv: string[]): Promise<number> {
|
||||
return refresh(flags);
|
||||
case 'fs':
|
||||
return fileManager(flags);
|
||||
case 'note':
|
||||
case 'notes':
|
||||
return notes(flags);
|
||||
case 'token':
|
||||
return token(flags);
|
||||
default:
|
||||
@@ -207,6 +222,87 @@ async function fileManager(flags: Flags): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
async function notes(flags: Flags): Promise<number> {
|
||||
const [sub, ...args] = flags._ as string[];
|
||||
const out = (line: string) => process.stdout.write(`${line}\n`);
|
||||
|
||||
// `--out` writes files directly, which is the one note command that needs no
|
||||
// server: a migration should be runnable and inspectable before anything is
|
||||
// sent anywhere.
|
||||
const offlineImport = sub === 'import' && Boolean(flags.out);
|
||||
const api = offlineImport ? undefined : new ApiClient(await loadCliConfig());
|
||||
|
||||
switch (sub) {
|
||||
case 'ls':
|
||||
case 'list':
|
||||
return noteList(
|
||||
api!,
|
||||
{
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.since ? { since: String(flags.since) } : {}),
|
||||
...(flags.until ? { until: String(flags.until) } : {}),
|
||||
},
|
||||
Boolean(flags.long),
|
||||
out,
|
||||
);
|
||||
case 'show':
|
||||
case 'cat':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note show needs a path, e.g.: schulcloud note show "Deutsch/2026-09-15 Erörterung.md"\n');
|
||||
return 2;
|
||||
}
|
||||
return noteShow(api!, args[0], out);
|
||||
case 'add': {
|
||||
const title = flags.title ? String(flags.title) : args[0];
|
||||
if (!title) {
|
||||
process.stderr.write('note add needs --title.\n');
|
||||
return 2;
|
||||
}
|
||||
// Piped text is the normal way in: it is how a note gets here from an
|
||||
// editor, a clipboard or another command. Typing it straight in works
|
||||
// too, but only if we say how it ends.
|
||||
if (!flags.text && process.stdin.isTTY) {
|
||||
process.stderr.write('Type the note, then Ctrl-D to save (Ctrl-C to abort):\n');
|
||||
}
|
||||
const body = flags.text ? String(flags.text) : await readPiped();
|
||||
if (!body?.trim()) {
|
||||
process.stderr.write('note add needs the note text: pass --text, or pipe it in.\n');
|
||||
return 2;
|
||||
}
|
||||
return noteAdd(
|
||||
api!,
|
||||
{
|
||||
title,
|
||||
text: body,
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.date ? { date: String(flags.date) } : {}),
|
||||
...(flags.tags ? { tags: String(flags.tags).split(',').map((tag) => tag.trim()).filter(Boolean) } : {}),
|
||||
append: Boolean(flags.append),
|
||||
},
|
||||
out,
|
||||
);
|
||||
}
|
||||
case 'import':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note import needs the export file, e.g.: schulcloud note import notes.ndjson\n');
|
||||
return 2;
|
||||
}
|
||||
return noteImport(
|
||||
api,
|
||||
args[0],
|
||||
{
|
||||
...(flags.out ? { outDir: resolve(String(flags.out)) } : {}),
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
dryRun: Boolean(flags['dry-run']),
|
||||
},
|
||||
out,
|
||||
);
|
||||
default:
|
||||
process.stderr.write(`Unknown note command "${sub ?? ''}". Use ls, show, add or import.\n\n${USAGE}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(flags: Flags): Promise<number> {
|
||||
const config = await loadCliConfig();
|
||||
const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir;
|
||||
|
||||
Reference in New Issue
Block a user