@wilm-ai/wilma-cli 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ import { SCHEDULE_WHEN, WilmaAiError, parseIsoDate } from "./agent-data.js";
2
+ import { normalizeResourceId } from "./downloads.js";
3
+ const DATA_COMMANDS = [
4
+ "summary",
5
+ "schedule",
6
+ "homework",
7
+ "exams",
8
+ "grades",
9
+ "gradebook",
10
+ "notes",
11
+ "messages",
12
+ "news",
13
+ "printouts",
14
+ "students",
15
+ ];
16
+ const OTHER_COMMANDS = ["accounts", "find-school", "login", "mcp", "update", "config", "help"];
17
+ const ALIASES = {
18
+ kids: "students",
19
+ tenants: "find-school",
20
+ attendance: "notes",
21
+ "lesson-notes": "notes",
22
+ absences: "notes",
23
+ exam: "exams",
24
+ message: "messages",
25
+ };
26
+ const MESSAGE_FOLDERS = ["inbox", "archive", "outbox", "drafts", "appointments"];
27
+ const WEEKDAY_WORDS = /^(mon|tue|wed|thu|fri|sat|sun|ma|ti|ke|to|pe|la|su|monday|tuesday|wednesday|thursday|friday|saturday|sunday)$/i;
28
+ const usage = (message) => new WilmaAiError("invalid_argument", `${message} See wilma --help.`);
29
+ export function parseCliArgs(argv) {
30
+ const flags = {};
31
+ const positionals = [];
32
+ const first = argv.find((arg) => !arg.startsWith("-"));
33
+ const firstCommand = first ? (ALIASES[first] ?? first) : null;
34
+ // Login and the MCP server take their own options.
35
+ if (firstCommand === "login" || firstCommand === "mcp") {
36
+ const at = argv.indexOf(first);
37
+ const raw = argv.slice(at + 1);
38
+ return { command: firstCommand, action: null, args: [], flags: { json: raw.includes("--json") }, raw };
39
+ }
40
+ const value = (i) => {
41
+ const next = argv[i + 1];
42
+ if (next === undefined || next.startsWith("--"))
43
+ throw usage(`${argv[i]} needs a value.`);
44
+ return next;
45
+ };
46
+ const positiveInt = (i, max) => {
47
+ const raw = value(i);
48
+ const n = Number(raw);
49
+ if (!Number.isInteger(n) || n < 1 || n > max)
50
+ throw usage(`${argv[i]} must be a whole number from 1 to ${max} (got "${raw}").`);
51
+ return n;
52
+ };
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ const take = () => {
56
+ const v = value(i);
57
+ i += 1;
58
+ return v;
59
+ };
60
+ switch (arg) {
61
+ case "--json":
62
+ flags.json = true;
63
+ break;
64
+ case "--text":
65
+ flags.text = true;
66
+ break;
67
+ case "--debug":
68
+ flags.debug = true;
69
+ break;
70
+ case "--older":
71
+ flags.older = true;
72
+ break;
73
+ case "--help":
74
+ case "-h":
75
+ flags.help = true;
76
+ break;
77
+ case "--version":
78
+ case "-v":
79
+ flags.version = true;
80
+ break;
81
+ case "--all-students":
82
+ case "--all":
83
+ // Every child is the default now; accepted for 1.x commands.
84
+ break;
85
+ case "--student":
86
+ flags.student = take();
87
+ break;
88
+ case "--limit":
89
+ flags.limit = positiveInt(i, 1000);
90
+ i += 1;
91
+ break;
92
+ case "--days":
93
+ flags.days = positiveInt(i, 365);
94
+ i += 1;
95
+ break;
96
+ case "--since":
97
+ flags.since = parseIsoDate(take());
98
+ break;
99
+ case "--from":
100
+ flags.from = parseIsoDate(take());
101
+ break;
102
+ case "--to":
103
+ flags.to = parseIsoDate(take());
104
+ break;
105
+ case "--date":
106
+ flags.date = parseIsoDate(take());
107
+ break;
108
+ case "--when": {
109
+ const when = take();
110
+ if (!SCHEDULE_WHEN.includes(when))
111
+ throw usage(`--when must be one of ${SCHEDULE_WHEN.join(", ")} (got "${when}").`);
112
+ flags.when = when;
113
+ break;
114
+ }
115
+ case "--weekday":
116
+ flags.weekday = take();
117
+ break;
118
+ case "--folder": {
119
+ const folder = take();
120
+ if (!MESSAGE_FOLDERS.includes(folder))
121
+ throw usage(`--folder must be one of ${MESSAGE_FOLDERS.join(", ")} (got "${folder}").`);
122
+ flags.folder = folder;
123
+ break;
124
+ }
125
+ case "--output":
126
+ flags.output = take();
127
+ break;
128
+ case "--totp-secret":
129
+ flags.totpSecret = take();
130
+ break;
131
+ default:
132
+ if (arg.startsWith("-"))
133
+ throw usage(`Unknown option "${arg}".`);
134
+ positionals.push(arg);
135
+ }
136
+ }
137
+ if (flags.json && flags.text)
138
+ throw usage("Use either --json or --text.");
139
+ if (!positionals.length)
140
+ return { command: null, action: null, args: [], flags, raw: argv };
141
+ const [name, ...rest] = positionals;
142
+ const command = ALIASES[name] ?? name;
143
+ if (!DATA_COMMANDS.includes(command) && !OTHER_COMMANDS.includes(command)) {
144
+ throw new WilmaAiError("unknown_command", `Unknown command "${name}". See wilma --help.`);
145
+ }
146
+ // `<command> list` is the 1.x spelling of the default action.
147
+ const args = rest[0] === "list" ? rest.slice(1) : rest;
148
+ const parsed = (action, values = []) => ({ command, action, args: values, flags, raw: argv });
149
+ const noMore = (extra) => {
150
+ if (extra.length)
151
+ throw usage(`Unexpected "${extra.join(" ")}" after ${command}.`);
152
+ };
153
+ switch (command) {
154
+ case "schedule": {
155
+ const [period, ...extra] = args;
156
+ noMore(extra);
157
+ if (period) {
158
+ if (SCHEDULE_WHEN.includes(period))
159
+ flags.when = period;
160
+ else if (WEEKDAY_WORDS.test(period))
161
+ flags.weekday = period;
162
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(period) || period === "yesterday")
163
+ flags.date = parseIsoDate(period);
164
+ else
165
+ throw usage(`Unknown period "${period}". Use today, tomorrow, week, next-week, a date (YYYY-MM-DD) or a weekday.`);
166
+ }
167
+ return parsed(null);
168
+ }
169
+ case "notes": {
170
+ if (args[0] === "summary") {
171
+ noMore(args.slice(1));
172
+ return parsed("summary");
173
+ }
174
+ noMore(args);
175
+ return parsed(null);
176
+ }
177
+ case "messages": {
178
+ const [first, second, ...extra] = args;
179
+ if (first === "read") {
180
+ noMore(extra);
181
+ return parsed("read", [checkId(second, "message")]);
182
+ }
183
+ if (first !== undefined) {
184
+ noMore([second, ...extra].filter((x) => x !== undefined));
185
+ return parsed("read", [checkId(first, "message")]);
186
+ }
187
+ return parsed(null);
188
+ }
189
+ case "news": {
190
+ const [first, second, third, fourth, ...extra] = args;
191
+ if (first === "read") {
192
+ noMore([third, fourth, ...extra].filter((x) => x !== undefined));
193
+ return parsed("read", [checkId(second, "news")]);
194
+ }
195
+ if (first === "resource") {
196
+ if (second !== "download")
197
+ throw usage('Expected "wilma news <id> download <resource>".');
198
+ noMore(extra);
199
+ return parsed("download", [checkId(third, "news"), checkResource(fourth)]);
200
+ }
201
+ if (first !== undefined) {
202
+ const id = checkId(first, "news");
203
+ if (second === undefined)
204
+ return parsed("read", [id]);
205
+ if (second !== "download")
206
+ throw usage(`Unexpected "${second}" after news ${first}.`);
207
+ noMore([fourth, ...extra].filter((x) => x !== undefined));
208
+ return parsed("download", [id, checkResource(third)]);
209
+ }
210
+ return parsed(null);
211
+ }
212
+ case "printouts": {
213
+ const [first, second, ...extra] = args;
214
+ if (first === "download") {
215
+ noMore(extra);
216
+ return parsed("download", [checkId(second, "printout")]);
217
+ }
218
+ if (first !== undefined) {
219
+ noMore([second, ...extra].filter((x) => x !== undefined));
220
+ return parsed("download", [checkId(first, "printout")]);
221
+ }
222
+ return parsed(null);
223
+ }
224
+ case "accounts":
225
+ if (args[0] === "remove")
226
+ return parsed("remove", args.slice(1));
227
+ noMore(args);
228
+ return parsed(null);
229
+ case "config":
230
+ if (args[0] !== "clear" || args.length > 1)
231
+ throw usage('Expected "wilma config clear".');
232
+ return parsed("clear");
233
+ case "find-school":
234
+ if (!args.length)
235
+ throw usage('Expected "wilma find-school <city or school>".');
236
+ return parsed(null, args);
237
+ case "help":
238
+ return parsed(null, args.slice(0, 1).map((topic) => ALIASES[topic] ?? topic));
239
+ default:
240
+ noMore(args);
241
+ return parsed(null);
242
+ }
243
+ }
244
+ function checkId(raw, entity) {
245
+ if (!raw)
246
+ throw usage(`Missing ${entity} id.`);
247
+ if (!/^\d+$/.test(raw) || Number(raw) <= 0)
248
+ throw usage(`Invalid ${entity} id "${raw}". Expected a positive whole number.`);
249
+ return raw;
250
+ }
251
+ function checkResource(raw) {
252
+ const id = normalizeResourceId(raw);
253
+ if (!id)
254
+ throw usage("Missing resource id (e.g. 1, or resource-1 from the bulletin's resources).");
255
+ return id;
256
+ }
@@ -0,0 +1,2 @@
1
+ export declare function generalHelp(): string;
2
+ export declare function commandHelp(command: string): string | null;
@@ -0,0 +1,141 @@
1
+ import { ENV_VARS } from "./credentials.js";
2
+ const STUDENT = "--student <number|name>";
3
+ const COMMAND_HELP = {
4
+ summary: {
5
+ usage: [`wilma summary [--since <date>] [--days 7] [${STUDENT}]`],
6
+ about: "The daily briefing for every child: today's and the next school day's lessons, upcoming exams (with start times when the school gives one), recent homework, lesson notes since the previous school day (teachers' feedback and absences), recent bulletins, and recent and unread messages. Start here.",
7
+ examples: ["wilma summary", "wilma summary --since yesterday", `wilma summary --student Kiia`],
8
+ output: "{ generatedAt, students: [{ student, summary: { today, tomorrow, todaySchedule, tomorrowSchedule, upcomingExams, homework, lessonNotes, news, messages, unreadMessages } }] }. --since <date> keeps only what is from that day on (for daily runs); messages carry unread and replyCount.",
9
+ },
10
+ schedule: {
11
+ usage: [`wilma schedule [today|tomorrow|week|next-week|<YYYY-MM-DD>|<weekday>] [${STUDENT}]`],
12
+ about: "Lessons with times, teachers and rooms. Default: this week. 'tomorrow' is the next school day; a weekday (mon…sun, or ma…su) is its next occurrence.",
13
+ examples: ["wilma schedule tomorrow", "wilma schedule next-week", "wilma schedule 2026-10-07", "wilma schedule thu --student Kiia"],
14
+ output: "{ when, date | weekStart+weekEnd, students: [{ student, lessons: [{ date, start, end, subject, teacher, room }] }] }",
15
+ },
16
+ homework: {
17
+ usage: [`wilma homework [--limit 10] [${STUDENT}]`],
18
+ about: "Recent homework by lesson, newest first.",
19
+ examples: ["wilma homework", "wilma homework --limit 30"],
20
+ },
21
+ exams: {
22
+ usage: [`wilma exams [--limit 20] [${STUDENT}]`],
23
+ about: "Upcoming exams with subject, name, topic (what to study), teacher and the start time when the school gives one.",
24
+ examples: ["wilma exams"],
25
+ output: "{ students: [{ student, exams: [{ date, time, subject, name, topic, teacher }] }] }",
26
+ },
27
+ grades: {
28
+ usage: [`wilma grades [--limit 20] [${STUDENT}]`],
29
+ about: "Recent exam grades. For course and report-card grades, see `wilma gradebook`.",
30
+ examples: ["wilma grades"],
31
+ },
32
+ gradebook: {
33
+ usage: [`wilma gradebook [${STUDENT}]`],
34
+ about: "Completed courses and grades (Suoritukset) by subject, including term and school-year (report card) grades, with credits and dates.",
35
+ examples: ["wilma gradebook --student Kiia"],
36
+ output: "{ students: [{ student, gradebook: [{ name, code, grade, credits, date, children: [...] }] }] }",
37
+ },
38
+ notes: {
39
+ usage: [`wilma notes [--date <date> | --days 14 | --from <date> [--to <date>]] [${STUDENT}]`, `wilma notes summary [--from <date>] [--to <date>] [${STUDENT}]`],
40
+ about: "Lesson notes (merkinnät) teachers log: absences, lateness, and feedback such as praise or missing books or homework, with the teacher's own words in `note`. One day (default today) or a period. `notes summary` counts them by kind for the school year or a period.",
41
+ examples: ["wilma notes --date yesterday", "wilma notes --days 14", "wilma notes summary"],
42
+ output: "{ date | from+to, students: [{ student, notes: [{ date, start, end, subject, typeLabel, teacher, note }] }] }",
43
+ },
44
+ messages: {
45
+ usage: [`wilma messages [--folder inbox|outbox|appointments|archive|drafts] [--limit 20] [${STUDENT}]`, `wilma messages <id> [${STUDENT}]`],
46
+ about: "Messages, newest first (subject, sender, date, unread, replyCount), or one message with its whole thread of replies.",
47
+ examples: ["wilma messages", "wilma messages --folder appointments", "wilma messages 27164611"],
48
+ },
49
+ news: {
50
+ usage: [`wilma news [--limit 20] [--older] [${STUDENT}]`, `wilma news <id> [${STUDENT}]`, `wilma news <id> download <resource> [--output <directory>] [${STUDENT}]`],
51
+ about: "School bulletins (tiedotteet): the newest dated ones and every pinned one (pinned: true); --older adds older bulletins (archived: true). `news <id>` reads one, with its linked resources; `download` fetches a resource (a file, or not_a_file when the link is a web page).",
52
+ examples: ["wilma news", "wilma news 73291", "wilma news 73291 download 1 --output ./attachments"],
53
+ },
54
+ printouts: {
55
+ usage: [`wilma printouts [${STUDENT}]`, `wilma printouts <id> [--output <directory>] [${STUDENT}]`],
56
+ about: "PDF documents the school offers (Tulosteet), such as report cards or absence reports; `printouts <id>` downloads one.",
57
+ examples: ["wilma printouts", "wilma printouts 24719 --output ~/Downloads"],
58
+ },
59
+ students: {
60
+ usage: ["wilma students"],
61
+ about: "The children on the saved logins (number, name, and their Wilma when there are several).",
62
+ examples: ["wilma students"],
63
+ },
64
+ login: {
65
+ usage: ["wilma login [--no-browser]", "wilma login --tenant <url|city> --username <name> [--password-stdin] [--totp-secret <key>]"],
66
+ about: "Log in to Wilma. Opens a one-time login page in the browser where the parent picks their school's Wilma; logins are saved on this computer. Families with children on different Wilmas add each one there. Without a browser, pass --tenant and --username with the password on stdin or in WILMA_PASSWORD.",
67
+ examples: ["wilma login"],
68
+ },
69
+ accounts: {
70
+ usage: ["wilma accounts", "wilma accounts remove <number|Wilma name|username>"],
71
+ about: "Saved Wilma logins, numbered (one per Wilma the children use); remove one.",
72
+ examples: ["wilma accounts", "wilma accounts remove 2"],
73
+ },
74
+ "find-school": {
75
+ usage: ["wilma find-school <city or school>"],
76
+ about: "Find a Wilma address. Most city schools share their city's Wilma (big cities don't list each school); colleges, private schools and many small municipalities' schools have their own.",
77
+ examples: ["wilma find-school tampere", "wilma find-school kalevan lukio"],
78
+ },
79
+ mcp: {
80
+ usage: ["wilma mcp"],
81
+ about: "Run the MCP server over stdio for Claude, ChatGPT and other assistants (the same data as these commands, as tools).",
82
+ examples: ["npx -y @wilm-ai/wilma-cli@2 mcp"],
83
+ },
84
+ update: { usage: ["wilma update"], about: "Update the CLI with npm.", examples: ["wilma update"] },
85
+ config: { usage: ["wilma config clear"], about: "Delete every saved login and session on this computer.", examples: ["wilma config clear"] },
86
+ };
87
+ const OVERVIEW = [
88
+ ["summary", "daily briefing for every child (start here)"],
89
+ ["schedule [when]", "lessons: today, tomorrow, week, next-week, a date or a weekday"],
90
+ ["exams", "upcoming exams with topics and start times"],
91
+ ["homework", "recent homework"],
92
+ ["notes [summary]", "lesson notes: absences and teachers' feedback; summary counts them"],
93
+ ["messages [id]", "messages, or one with its replies"],
94
+ ["news [id [download r]]", "bulletins, one bulletin, or a linked file"],
95
+ ["grades", "recent exam grades"],
96
+ ["gradebook", "course and report-card grades"],
97
+ ["printouts [id]", "PDFs such as report cards"],
98
+ ["students", "the children"],
99
+ ["login", "log in (opens a page in the browser)"],
100
+ ["accounts", "saved logins; accounts remove <n>"],
101
+ ["find-school <query>", "find a Wilma address by city or school"],
102
+ ["mcp", "MCP server for AI assistants"],
103
+ ["update", "update the CLI"],
104
+ ];
105
+ export function generalHelp() {
106
+ const width = Math.max(...OVERVIEW.map(([name]) => name.length));
107
+ return [
108
+ "WilmAI: read Finland's Wilma school system (read-only).",
109
+ "",
110
+ "Usage: wilma <command> [options]",
111
+ "",
112
+ ...OVERVIEW.map(([name, about]) => ` ${name.padEnd(width)} ${about}`),
113
+ "",
114
+ "Every command covers all children; --student <number|name> narrows to one.",
115
+ "Output is JSON when another program reads it (agents, pipes) and text in a terminal;",
116
+ "--json or --text forces either. Dates are YYYY-MM-DD (or today, yesterday, tomorrow);",
117
+ "times in JSON are Finnish time with their offset.",
118
+ "",
119
+ "Errors are JSON too: { status: \"error\", code, message } with exit code 2 for usage",
120
+ "errors, 3 when not logged in, 1 otherwise.",
121
+ "",
122
+ `Without a saved login, set ${ENV_VARS.tenant}, ${ENV_VARS.username} and ${ENV_VARS.password}`,
123
+ `(plus ${ENV_VARS.totpSecret} for two-step verification). The password is never a command-line flag.`,
124
+ "",
125
+ "More: wilma help <command> · wilma --version",
126
+ ].join("\n");
127
+ }
128
+ export function commandHelp(command) {
129
+ const help = COMMAND_HELP[command];
130
+ if (!help)
131
+ return null;
132
+ return [
133
+ ...help.usage.map((line, i) => `${i === 0 ? "Usage: " : " "}${line}`),
134
+ "",
135
+ help.about,
136
+ ...(help.output ? ["", `JSON: ${help.output}`] : []),
137
+ "",
138
+ "Examples:",
139
+ ...help.examples.map((example) => ` ${example}`),
140
+ ].join("\n");
141
+ }
@@ -0,0 +1,6 @@
1
+ import type { Message, NewsItem } from "@wilm-ai/wilma-client";
2
+ export declare function compactText(value: string | null | undefined): string;
3
+ export declare function newsLine(item: Pick<NewsItem, "wilmaId" | "title" | "published" | "pinned" | "archived">): string;
4
+ export declare function messageLine(msg: Pick<Message, "wilmaId" | "subject" | "sentAt" | "senderName" | "unread" | "replyCount">): string;
5
+ /** Text for a command's result. */
6
+ export declare function formatText(command: string, action: string | null, result: any): string;
@@ -0,0 +1,184 @@
1
+ import { finnishIsoString } from "@wilm-ai/wilma-client";
2
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
3
+ export function compactText(value) {
4
+ return (value ?? "").replace(/\s+/g, " ").trim();
5
+ }
6
+ /** Multi-line text with single blank lines between blocks. */
7
+ function formatContent(value) {
8
+ const lines = value
9
+ .replace(/\r/g, "")
10
+ .split("\n")
11
+ .map((line) => line.trim())
12
+ .filter((line, index, arr) => line !== "" || arr[index - 1] !== "");
13
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
14
+ }
15
+ /** "2026-10-02 13:37" in Finnish time. */
16
+ function finnishDateTime(d) {
17
+ if (!d || Number.isNaN(d.getTime()) || d.getTime() <= 0)
18
+ return "";
19
+ return finnishIsoString(d).slice(0, 16).replace("T", " ");
20
+ }
21
+ function finnishDay(d) {
22
+ return finnishDateTime(d).slice(0, 10);
23
+ }
24
+ function weekday(isoDate) {
25
+ return DAY_NAMES[new Date(`${isoDate}T12:00:00Z`).getUTCDay()];
26
+ }
27
+ function heading(student, several) {
28
+ if (!several)
29
+ return [];
30
+ return ["", `${student.name}${student.wilma ? ` (${student.wilma})` : ""}`];
31
+ }
32
+ /** A title line, then the per-child sections (a blank line between when there are several children). */
33
+ function titled(title, result, body) {
34
+ return result.students.length > 1 ? `${title}\n\n${body}` : `${title}\n${body}`;
35
+ }
36
+ function perStudent(result, key, render) {
37
+ const several = result.students.length > 1;
38
+ const lines = [];
39
+ for (const entry of result.students) {
40
+ lines.push(...heading(entry.student, several), ...render(entry[key], entry.student));
41
+ }
42
+ for (const problem of result.problems ?? [])
43
+ lines.push("", `Couldn't read ${problem.wilma}: ${problem.message}`);
44
+ return lines.join("\n").replace(/^\n/, "");
45
+ }
46
+ function lessonLine(l) {
47
+ const teacher = l.teacher ? ` — ${l.teacher}` : "";
48
+ const room = l.room ? `, ${l.room}` : "";
49
+ return `${l.start}-${l.end} ${l.subject}${teacher}${room}`;
50
+ }
51
+ function examLine(exam) {
52
+ const when = exam.time ? `${exam.date} ${exam.time}` : exam.date;
53
+ const topic = exam.topic ? ` — ${compactText(exam.topic)}` : "";
54
+ return `${when} ${exam.subject}${exam.name ? `: ${exam.name}` : ""}${topic}`;
55
+ }
56
+ export function newsLine(item) {
57
+ const date = item.published ? `${finnishDay(item.published)} ` : "";
58
+ const mark = item.pinned ? " [pinned]" : item.archived ? " [older]" : "";
59
+ return `${date}${compactText(item.title)}${mark} (id ${item.wilmaId})`;
60
+ }
61
+ export function messageLine(msg) {
62
+ const sender = msg.senderName ? ` — ${compactText(msg.senderName)}` : "";
63
+ const replies = msg.replyCount ? ` [${msg.replyCount} ${msg.replyCount === 1 ? "reply" : "replies"}]` : "";
64
+ return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies} (id ${msg.wilmaId})`;
65
+ }
66
+ function lessonNoteLine(note, withDate) {
67
+ const date = withDate ? `${note.date} ` : "";
68
+ const time = note.start ? `${note.start} ` : "";
69
+ const subject = note.subject ? ` [${note.subject}]` : "";
70
+ const teacher = note.teacher ? ` ${note.teacher}` : "";
71
+ const words = note.note ? `: ${compactText(note.note)}` : "";
72
+ return `${date}${time}${note.typeLabel}${subject}${teacher}${words}`;
73
+ }
74
+ function scheduleLines(lessons) {
75
+ if (!lessons.length)
76
+ return [" No lessons."];
77
+ const lines = [];
78
+ let day = "";
79
+ for (const lesson of lessons) {
80
+ if (lesson.date !== day) {
81
+ day = lesson.date;
82
+ lines.push(` ${weekday(day)} ${day}`);
83
+ }
84
+ lines.push(` ${lessonLine(lesson)}`);
85
+ }
86
+ return lines;
87
+ }
88
+ function gradebookLines(entries, depth = 0) {
89
+ if (!entries.length && depth === 0)
90
+ return [" No graded courses yet."];
91
+ return entries.flatMap((entry) => {
92
+ const code = entry.code ? `${entry.code} ` : "";
93
+ const grade = entry.grade ? ` ${entry.grade}` : "";
94
+ const credits = entry.credits ? ` (${entry.credits})` : "";
95
+ const date = entry.date ? ` ${entry.date}` : "";
96
+ return [`${" ".repeat(depth + 1)}${code}${compactText(entry.name)}${grade}${credits}${date}`, ...gradebookLines(entry.children, depth + 1)];
97
+ });
98
+ }
99
+ function summaryLines(s) {
100
+ const section = (title, lines, empty) => lines.length ? ["", title, ...lines.map((line) => ` ${line}`)] : empty ? ["", title, ` ${empty}`] : [];
101
+ return [
102
+ ...section(`Today ${weekday(s.today)} ${s.today}`, s.todaySchedule.map(lessonLine), "No lessons."),
103
+ ...section(`Next school day ${weekday(s.tomorrow)} ${s.tomorrow}`, s.tomorrowSchedule.map(lessonLine), "No lessons."),
104
+ ...section("Upcoming exams", s.upcomingExams.map(examLine)),
105
+ ...section("Homework", s.homework.map((h) => `${h.date} ${h.subject}: ${compactText(h.homework)}`)),
106
+ ...section("Lesson notes", s.lessonNotes.map((n) => lessonNoteLine(n, true))),
107
+ ...section("Bulletins", s.news.map((n) => newsLine(n))),
108
+ ...section(`Messages${s.unreadMessages ? ` (${s.unreadMessages} unread)` : ""}`, s.messages.map((m) => messageLine(m))),
109
+ ...(s.unavailable?.length ? ["", `(Not available from Wilma right now: ${s.unavailable.join(", ")})`] : []),
110
+ ];
111
+ }
112
+ /** Text for a command's result. */
113
+ export function formatText(command, action, result) {
114
+ switch (command) {
115
+ case "summary":
116
+ return perStudent(result, "summary", (s) => summaryLines(s));
117
+ case "schedule": {
118
+ const period = result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
119
+ return titled(`Schedule ${period}`, result, perStudent(result, "lessons", scheduleLines));
120
+ }
121
+ case "homework":
122
+ return perStudent(result, "homework", (items) => items.length ? items.map((h) => ` ${h.date} ${h.subject}: ${compactText(h.homework)}`) : [" No recent homework."]);
123
+ case "exams":
124
+ return perStudent(result, "exams", (items) => (items.length ? items.map((e) => ` ${examLine(e)}`) : [" No upcoming exams."]));
125
+ case "grades":
126
+ return perStudent(result, "grades", (items) => items.length ? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade}`) : [" No exam grades."]);
127
+ case "gradebook":
128
+ return perStudent(result, "gradebook", (entries) => gradebookLines(entries));
129
+ case "notes": {
130
+ if (action === "summary") {
131
+ return perStudent(result, "summary", (s) => [
132
+ ` Lesson notes, ${s.from ? `${s.from} – ${s.to}` : "this school year"}: ${s.total}`,
133
+ ...s.byType.map((t) => ` ${t.type}: ${t.count}`),
134
+ ]);
135
+ }
136
+ const period = result.from ? `${result.from} – ${result.to}` : result.date;
137
+ return titled(`Lesson notes ${period}`, result, perStudent(result, "notes", (notes) => notes.length ? notes.map((n) => ` ${lessonNoteLine(n, Boolean(result.from))}`) : [" No lesson notes."]));
138
+ }
139
+ case "messages": {
140
+ if (action === "read") {
141
+ const msg = result.message;
142
+ return [
143
+ msg.subject,
144
+ ...(msg.senderName ? [`From: ${msg.senderName}`] : []),
145
+ ...(msg.recipients?.length ? [`To: ${msg.recipients.join(", ")}`] : []),
146
+ `Sent: ${finnishDateTime(msg.sentAt)}`,
147
+ ...(msg.content ? ["", formatContent(msg.content)] : []),
148
+ ...(msg.replies ?? []).flatMap((reply) => [
149
+ "",
150
+ `--- Reply from ${reply.senderName ?? "unknown"}, ${finnishDateTime(reply.sentAt)}`,
151
+ ...(reply.content ? [formatContent(reply.content)] : []),
152
+ ]),
153
+ ].join("\n");
154
+ }
155
+ return perStudent(result, "messages", (items) => (items.length ? items.map((m) => ` ${messageLine(m)}`) : [" No messages."]));
156
+ }
157
+ case "news": {
158
+ if (action === "read") {
159
+ const item = result.news;
160
+ return [
161
+ item.title,
162
+ ...(item.subtitle ? [item.subtitle] : []),
163
+ ...(item.published ? [`Published: ${finnishDay(item.published)}${item.author ? ` by ${item.author}` : ""}`] : []),
164
+ ...(item.content ? ["", formatContent(item.content)] : []),
165
+ ...(item.resources?.length
166
+ ? ["", `Links (${item.resources.length}):`, ...item.resources.map((r) => ` [${r.id}] ${compactText(r.label)} — ${r.url}`), "", `Download one: wilma news ${item.wilmaId} download <resource>`]
167
+ : []),
168
+ ].join("\n");
169
+ }
170
+ return perStudent(result, "news", (items) => {
171
+ const lines = items.length ? items.map((n) => ` ${newsLine(n)}`) : [" No bulletins."];
172
+ return lines;
173
+ });
174
+ }
175
+ case "printouts":
176
+ return perStudent(result, "printouts", (items) => items.length ? [...items.map((p) => ` ${compactText(p.title)} (id ${p.id})`)] : [" No printouts."]);
177
+ case "students":
178
+ return result.students
179
+ .map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}`)
180
+ .join("\n");
181
+ default:
182
+ return JSON.stringify(result, null, 2);
183
+ }
184
+ }