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>
469 lines
17 KiB
JavaScript
469 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createWriteStream } from 'node:fs';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { basename, dirname, resolve } from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
|
|
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';
|
|
|
|
/**
|
|
* `schulcloud` — the command-line front end.
|
|
*
|
|
* Speaks only to the schulcloud-mcp server, never to Schulcloud: the Pi holds
|
|
* the one Schulcloud session and keeps it alive, so this machine stores nothing
|
|
* but a bearer token. See docs/CLI.md.
|
|
*/
|
|
|
|
const USAGE = `schulcloud — browse and mirror your Schulcloud files
|
|
|
|
schulcloud login --server <url> --token <token> [--dir <path>]
|
|
schulcloud status
|
|
schulcloud ls [--course <id>] [--files] [--long]
|
|
schulcloud get <fileId> [--out <path>]
|
|
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
|
schulcloud refresh [--course <id>] [--force]
|
|
schulcloud token when the server's Schulcloud token expires
|
|
schulcloud token set hand the server a fresh one (paste, or pipe it in)
|
|
|
|
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
|
|
|
schulcloud fs ls [path] [--long]
|
|
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
|
|
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
|
|
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
|
|
|
|
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.
|
|
|
|
Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and
|
|
SCHULCLOUD_SYNC_DIR. Config file: ${configPath()}
|
|
`;
|
|
|
|
async function main(argv: string[]): Promise<number> {
|
|
// A leading flag means no command was given: `schulcloud --help` must not be
|
|
// read as a command called "--help".
|
|
const hasCommand = argv[0] !== undefined && !argv[0].startsWith('-');
|
|
const command = hasCommand ? argv[0] : undefined;
|
|
const flags = parseFlags(hasCommand ? argv.slice(1) : argv);
|
|
|
|
if (!command || command === 'help' || flags.help || flags.h) {
|
|
process.stdout.write(USAGE);
|
|
return 0;
|
|
}
|
|
|
|
switch (command) {
|
|
case 'login':
|
|
return login(flags);
|
|
case 'status':
|
|
return status();
|
|
case 'ls':
|
|
return list(flags);
|
|
case 'get':
|
|
return get(flags);
|
|
case 'sync':
|
|
return runSync(flags);
|
|
case 'refresh':
|
|
return refresh(flags);
|
|
case 'fs':
|
|
return fileManager(flags);
|
|
case 'note':
|
|
case 'notes':
|
|
return notes(flags);
|
|
case 'token':
|
|
return token(flags);
|
|
default:
|
|
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
async function login(flags: Flags): Promise<number> {
|
|
const server = String(flags.server ?? '');
|
|
const token = String(flags.token ?? '');
|
|
if (!server || !token) {
|
|
process.stderr.write('login needs --server and --token.\n');
|
|
return 2;
|
|
}
|
|
const syncDir = flags.dir ? resolve(String(flags.dir)) : defaultSyncDir();
|
|
const config = { server: server.replace(/\/+$/, ''), token, syncDir };
|
|
|
|
// Verify before saving, so a typo fails now rather than on first real use.
|
|
try {
|
|
await new ApiClient(config).status();
|
|
} catch (error) {
|
|
process.stderr.write(`Could not reach the server: ${(error as Error).message}\n`);
|
|
return 1;
|
|
}
|
|
const path = await saveCliConfig(config);
|
|
process.stdout.write(`Saved ${path}\n server: ${config.server}\n sync dir: ${config.syncDir}\n`);
|
|
return 0;
|
|
}
|
|
|
|
async function status(): Promise<number> {
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const info = (await api.status()) as {
|
|
crawlId?: number; crawledAt?: string; nodes?: number; files?: number;
|
|
extracted?: number; mirrored?: number; indexer?: { running?: boolean; scope?: string } | null;
|
|
};
|
|
if (info.crawlId === undefined) {
|
|
process.stdout.write('The server index is empty. Run: schulcloud refresh\n');
|
|
return 0;
|
|
}
|
|
const age = info.crawledAt ? Math.round((Date.now() - new Date(info.crawledAt).getTime()) / 60_000) : undefined;
|
|
process.stdout.write(
|
|
`generation ${info.crawlId}${age !== undefined ? ` — crawled ${age} min ago` : ''}\n` +
|
|
` ${info.nodes} items, ${info.files} files\n` +
|
|
` ${info.extracted} with extracted text, ${info.mirrored} mirrored on the server\n` +
|
|
(info.indexer?.running ? ` a re-crawl is running (${info.indexer.scope})\n` : ''),
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
async function list(flags: Flags): Promise<number> {
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const manifest = await api.manifest();
|
|
let entries = manifest.entries.filter((entry) => entry.status !== 'removed');
|
|
// A room id works here too: the manifest's courseId is the container id, and
|
|
// since rooms were added that container can be a room.
|
|
if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course);
|
|
|
|
if (entries.length === 0) {
|
|
process.stdout.write('No files.\n');
|
|
return 0;
|
|
}
|
|
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
|
|
for (const entry of entries) {
|
|
if (flags.long) {
|
|
process.stdout.write(`${entry.fileId} ${String(formatBytes(entry.size)).padStart(9)} ${entry.path}\n`);
|
|
} else {
|
|
process.stdout.write(`${entry.path}\n`);
|
|
}
|
|
}
|
|
process.stderr.write(`\n${entries.length} file(s), generation ${manifest.cursor}\n`);
|
|
return 0;
|
|
}
|
|
|
|
async function get(flags: Flags): Promise<number> {
|
|
const fileId = String(flags._[0] ?? '');
|
|
if (!fileId) {
|
|
process.stderr.write('get needs a file id (see: schulcloud ls --long).\n');
|
|
return 2;
|
|
}
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const response = await api.file(fileId);
|
|
if (!response.body) {
|
|
process.stderr.write('Empty response.\n');
|
|
return 1;
|
|
}
|
|
|
|
const fromHeader = /filename\*=UTF-8''([^;]+)/.exec(response.headers.get('content-disposition') ?? '')?.[1];
|
|
const name = flags.out ? String(flags.out) : fromHeader ? decodeURIComponent(fromHeader) : fileId;
|
|
// basename() on the server-supplied name: it must not choose a directory.
|
|
const target = flags.out ? resolve(String(flags.out)) : resolve(basename(name));
|
|
await mkdir(dirname(target), { recursive: true });
|
|
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(target));
|
|
process.stdout.write(`${target}\n`);
|
|
return 0;
|
|
}
|
|
|
|
async function fileManager(flags: Flags): Promise<number> {
|
|
const [sub, ...args] = flags._ as string[];
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const out = (line: string) => process.stdout.write(`${line}\n`);
|
|
const long = Boolean(flags.long);
|
|
switch (sub) {
|
|
case 'ls':
|
|
return fsList(api, args[0] ?? '/', long, out);
|
|
case 'tree':
|
|
return fsTree(api, args[0] ?? '/', Number(flags.depth ?? 3), Number(flags['max-folders'] ?? 200), out);
|
|
case 'find': {
|
|
if (!args[0]) {
|
|
process.stderr.write('fs find needs a name, e.g.: schulcloud fs find Erbrecht --path /courses\n');
|
|
return 2;
|
|
}
|
|
const type = flags.type === 'folder' || flags.type === 'file' ? String(flags.type) : 'any';
|
|
return fsFind(api, args[0], String(flags.path ?? '/'), type, Number(flags['max-folders'] ?? 400), long, out);
|
|
}
|
|
case 'get':
|
|
if (!args[0]) {
|
|
process.stderr.write('fs get needs a path, e.g.: schulcloud fs get "/courses/<course>/<folder>"\n');
|
|
return 2;
|
|
}
|
|
return fsGet(
|
|
api,
|
|
args[0],
|
|
{ out: flags.out ? String(flags.out) : undefined, force: Boolean(flags.force), jobs: Number(flags.jobs ?? 3) },
|
|
out,
|
|
);
|
|
default:
|
|
process.stderr.write(`Unknown fs command "${sub ?? ''}". Use ls, tree, find or get.\n\n${USAGE}`);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
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;
|
|
const api = new ApiClient(config);
|
|
const dryRun = Boolean(flags['dry-run']);
|
|
|
|
process.stderr.write(`${dryRun ? 'Would sync' : 'Syncing'} to ${root}\n`);
|
|
|
|
const summary = await sync(api, root, {
|
|
dryRun,
|
|
prune: Boolean(flags.prune),
|
|
full: Boolean(flags.full),
|
|
concurrency: Number(flags.jobs ?? 4),
|
|
onEvent: (event) => process.stderr.write(describe(event, dryRun)),
|
|
});
|
|
|
|
process.stderr.write(
|
|
`\n${dryRun ? 'Would download' : 'Downloaded'} ${summary.downloaded} file(s) (${formatBytes(summary.bytes)})` +
|
|
`, moved ${summary.moved}, unchanged ${summary.skipped}` +
|
|
(summary.removed ? `, deleted ${summary.removed}` : '') +
|
|
(summary.kept ? `, ${summary.kept} gone upstream but kept locally` : '') +
|
|
(summary.failed ? `, FAILED ${summary.failed}` : '') +
|
|
`\ncursor now ${summary.cursor}\n`,
|
|
);
|
|
if (summary.kept > 0 && !flags.prune) {
|
|
process.stderr.write('Files removed upstream were kept. Pass --prune to delete them locally.\n');
|
|
}
|
|
return summary.failed > 0 ? 1 : 0;
|
|
}
|
|
|
|
async function refresh(flags: Flags): Promise<number> {
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const scope = flags.course ? String(flags.course) : undefined;
|
|
process.stderr.write(`Asking the server to re-crawl ${scope ? `course ${scope}` : 'everything'}…\n`);
|
|
let lastNote = 0;
|
|
const result = (await api.refresh(scope, Boolean(flags.force), (seconds) => {
|
|
// A note every half minute, so a long first crawl does not look hung.
|
|
if (seconds - lastNote >= 30) {
|
|
lastNote = seconds;
|
|
process.stderr.write(` still crawling… ${seconds}s\n`);
|
|
}
|
|
})) as {
|
|
crawlId: number; courses: number; files: number; mirrored: number; extracted: number;
|
|
skipped: number; durationMs: number; joined?: boolean;
|
|
};
|
|
process.stdout.write(
|
|
`${result.joined ? 'Joined a crawl already running. ' : ''}` +
|
|
`generation ${result.crawlId}: ${result.courses} course(s), ${result.files} files, ` +
|
|
`${result.mirrored} newly mirrored, ${result.extracted} text-extracted, ${result.skipped} skipped ` +
|
|
`(${(result.durationMs / 1000).toFixed(1)}s)\n`,
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
|
|
* cookie, paste it here, close the window. The server checks the token with
|
|
* Schulcloud before swapping it in, so a bad paste changes nothing.
|
|
*/
|
|
async function token(flags: Flags): Promise<number> {
|
|
const api = new ApiClient(await loadCliConfig());
|
|
const sub = flags._[0];
|
|
|
|
if (sub === undefined || sub === 'status') {
|
|
process.stdout.write(`${describeToken(await api.token())}\n`);
|
|
return 0;
|
|
}
|
|
if (sub !== 'set') {
|
|
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
|
|
return 2;
|
|
}
|
|
|
|
const pasted = process.stdin.isTTY
|
|
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
|
|
: await readPiped();
|
|
if (!pasted.trim()) {
|
|
process.stderr.write('No token given.\n');
|
|
return 2;
|
|
}
|
|
process.stderr.write('Checking it with Schulcloud…\n');
|
|
const result = await api.replaceToken(pasted);
|
|
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
|
|
if (result.changed && !result.persisted) {
|
|
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
|
|
}
|
|
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
|
|
return 0;
|
|
}
|
|
|
|
function describeToken(info: TokenInfo): string {
|
|
const expiry = info.expiresAt
|
|
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
|
|
: 'expiry unknown';
|
|
const keepalive = info.keepalive;
|
|
const session = !keepalive
|
|
? 'keepalive off'
|
|
: keepalive.running
|
|
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
|
|
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
|
|
const source =
|
|
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
|
|
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
|
|
return `${expiry}; ${session}; ${source}${warning}`;
|
|
}
|
|
|
|
function describe(event: SyncEvent, dryRun: boolean): string {
|
|
switch (event.type) {
|
|
case 'download':
|
|
return ` ${dryRun ? 'would get' : 'get '} ${event.entry.path}${event.reason === 'new' ? '' : ` (${event.reason})`}\n`;
|
|
case 'move':
|
|
return ` ${dryRun ? 'would move' : 'move '} ${event.from} → ${event.entry.path}\n`;
|
|
case 'remove':
|
|
return ` ${event.kept ? 'gone upstream, kept' : dryRun ? 'would delete' : 'delete '} ${event.path}\n`;
|
|
case 'error':
|
|
return ` FAILED ${event.entry.path}: ${event.message}\n`;
|
|
case 'skip':
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// --- flags ---------------------------------------------------------------
|
|
|
|
interface Flags {
|
|
_: string[];
|
|
[key: string]: string | boolean | string[] | undefined;
|
|
}
|
|
|
|
/** Minimal flag parsing: --key value, --key=value, --flag, and positionals. */
|
|
function parseFlags(argv: string[]): Flags {
|
|
const flags: Flags = { _: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i]!;
|
|
if (!token.startsWith('--')) {
|
|
(flags._ as string[]).push(token);
|
|
continue;
|
|
}
|
|
const body = token.slice(2);
|
|
const eq = body.indexOf('=');
|
|
if (eq !== -1) {
|
|
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
continue;
|
|
}
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith('--')) {
|
|
flags[body] = next;
|
|
i++;
|
|
} else {
|
|
flags[body] = true;
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
main(process.argv.slice(2))
|
|
.then((code) => process.exit(code))
|
|
.catch((error: unknown) => {
|
|
if (error instanceof ApiError) {
|
|
process.stderr.write(`${error.message}\n`);
|
|
} else {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
process.exit(1);
|
|
});
|