@wilm-ai/wilma-cli 2.3.1 → 2.4.0

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.
@@ -104,13 +104,28 @@ function nextDateForWeekday(rawWeekday) {
104
104
  return addDays(today, (target - weekdayOf(today) + 7) % 7);
105
105
  }
106
106
  export const SCHEDULE_WHEN = ["today", "tomorrow", "week", "next-week"];
107
+ /** The longest period one schedule request covers. */
108
+ export const SCHEDULE_MAX_DAYS = 31;
107
109
  function resolveScheduleDateSelection(opts) {
108
110
  const when = opts.when || "week";
109
111
  if (!SCHEDULE_WHEN.includes(when)) {
110
112
  throw new WilmaAiError("invalid_argument", `Unknown period "${when}". Use ${SCHEDULE_WHEN.join(", ")}, a date or a weekday.`);
111
113
  }
112
- if (opts.date && opts.weekday) {
113
- throw new WilmaAiError("invalid_argument", "Use either a date or a weekday, not both.");
114
+ if ([opts.date, opts.weekday, opts.from].filter(Boolean).length > 1) {
115
+ throw new WilmaAiError("invalid_argument", "Use one of a date, a weekday, or from/to.");
116
+ }
117
+ if (opts.to && !opts.from)
118
+ throw new WilmaAiError("invalid_argument", "A period needs a start date (from) as well.");
119
+ if (opts.from) {
120
+ const from = parseIsoDate(opts.from);
121
+ // Without an end: to the Friday of that week (a school week from any day).
122
+ const to = opts.to ? parseIsoDate(opts.to) : addDays(from, Math.max(0, 5 - weekdayOf(from)));
123
+ if (to < from)
124
+ throw new WilmaAiError("invalid_argument", `The period ends (${to}) before it starts (${from}).`);
125
+ if (addDays(from, SCHEDULE_MAX_DAYS - 1) < to) {
126
+ throw new WilmaAiError("invalid_argument", `A period can be at most ${SCHEDULE_MAX_DAYS} days; ask for a longer one in parts.`);
127
+ }
128
+ return { when, startDate: from, endDate: to, outputWhen: "period" };
114
129
  }
115
130
  if (opts.date) {
116
131
  const parsed = parseIsoDate(opts.date);
@@ -131,6 +146,9 @@ function resolveScheduleDateSelection(opts) {
131
146
  const [startDate, endDate] = currentWeekBounds(when === "next-week" ? 1 : 0);
132
147
  return { when, startDate, endDate, outputWhen: when };
133
148
  }
149
+ function listedInvitation(listing) {
150
+ return { ...listing.message, invitationStatus: listing.status, invitationInfo: listing.extra };
151
+ }
134
152
  /**
135
153
  * One child's daily briefing: today's and the next school day's lessons,
136
154
  * upcoming exams (with start times), recent homework, lesson notes (teachers'
@@ -161,6 +179,7 @@ function buildSummaryData(input, opts = {}) {
161
179
  folder: m.folder,
162
180
  unread: Boolean(m.unread),
163
181
  replyCount: m.replyCount ?? 0,
182
+ ...(m.invitationStatus !== undefined ? { invitationStatus: m.invitationStatus, invitationInfo: m.invitationInfo ?? null } : {}),
164
183
  }));
165
184
  return {
166
185
  today,
@@ -205,7 +224,8 @@ async function fetchSummaryInputs(client, opts) {
205
224
  client.exams.calendarOrEmpty(),
206
225
  client.news.listIfShown(),
207
226
  client.messages.list("inbox"),
208
- optional("appointments", client.messages.list("appointments"), []),
227
+ // The invitations folder, with whether the family has answered (one request, as for the folder itself).
228
+ optional("appointments", client.invitations.list(), []),
209
229
  optional("lessonNotes", client.attendance.list({ from: opts.notesFrom, to: todayString() }), []),
210
230
  ]);
211
231
  if (news === null)
@@ -213,7 +233,7 @@ async function fetchSummaryInputs(client, opts) {
213
233
  return {
214
234
  overview: { ...overview, upcomingExams: addExamTimes(overview.upcomingExams, calendar) },
215
235
  news: news ?? [],
216
- messages: [...inbox, ...appointments],
236
+ messages: [...inbox, ...appointments.map(listedInvitation)],
217
237
  lessonNotes,
218
238
  unavailable,
219
239
  };
@@ -223,6 +243,43 @@ function daysBack(days) {
223
243
  const today = todayString();
224
244
  return addDays(today, -(days - 1));
225
245
  }
246
+ /** What each teacher teaches, by teacher code as the timetable writes it ("KOJ" → ["Matematiikka"]). */
247
+ function subjectsByTeacher(lessons) {
248
+ const map = new Map();
249
+ for (const lesson of lessons) {
250
+ // A lesson can have several teachers ("KOJ, MÄL"): each teaches it.
251
+ for (const code of (lesson.teacherCode ?? "").split(",")) {
252
+ const key = code.trim().toUpperCase();
253
+ if (!key || !lesson.subject)
254
+ continue;
255
+ const list = map.get(key) ?? [];
256
+ if (!list.includes(lesson.subject))
257
+ map.set(key, [...list, lesson.subject]);
258
+ }
259
+ }
260
+ return map;
261
+ }
262
+ /** The teacher's code at the end of a name as Wilma's recipient lists write it: "Korhonen Jussi (KOJ)" → "KOJ". */
263
+ function codeInName(name) {
264
+ return /\(([^()\s]{1,12})\)\s*$/.exec(name)?.[1] ?? null;
265
+ }
266
+ /**
267
+ * The child's own teachers in Wilma's recipient list: the list behind its
268
+ * "all own teachers" group (whatever the Wilma's language calls it), once
269
+ * each. Without that group, none.
270
+ */
271
+ function ownTeachers(recipients) {
272
+ const group = recipients.find((r) => r.kind === "group" && /[?&]r_ownteachers=/.test(r.composePath))?.group;
273
+ if (!group)
274
+ return [];
275
+ const seen = new Set();
276
+ return recipients.filter((r) => {
277
+ if (r.kind !== "teacher" || r.group !== group || seen.has(r.name))
278
+ return false;
279
+ seen.add(r.name);
280
+ return true;
281
+ });
282
+ }
226
283
  /* ------------------------------------------------------------------ */
227
284
  /* Session pool */
228
285
  /* ------------------------------------------------------------------ */
@@ -386,9 +443,19 @@ export function matchStudents(students, student) {
386
443
  }
387
444
  throw new WilmaAiError("ambiguous_student", `"${student}" matches several students (${matches.map((s) => s.name).join(", ")}). Use the full name or student number.`, { students: list });
388
445
  }
446
+ /** Whose login it is: a student's own when Wilma lists only the student themself, a guardian's when it says so. */
447
+ export function roleOf(students) {
448
+ if (students.length && students.every((s) => s.role === "student"))
449
+ return "student";
450
+ return students.some((s) => s.role === "guardian") ? "guardian" : undefined;
451
+ }
389
452
  export class WilmaAccess {
390
453
  accounts;
391
454
  studentsCache = null;
455
+ /** The children each login lists (by login index), including those read through another login. */
456
+ listed = new Map();
457
+ /** Why a login failed (by login index), while others worked. */
458
+ failures = new Map();
392
459
  /** One logged-in session per Wilma login, shared by every child on it. */
393
460
  sessions = new Map();
394
461
  /** Logins that failed while other logins worked. */
@@ -431,7 +498,7 @@ export class WilmaAccess {
431
498
  }
432
499
  }));
433
500
  const all = [];
434
- const seen = new Set();
501
+ const seen = new Map();
435
502
  let firstError = null;
436
503
  let worked = 0;
437
504
  for (const result of fetched) {
@@ -440,6 +507,7 @@ export class WilmaAccess {
440
507
  firstError ??= result.error;
441
508
  const message = result.error instanceof Error ? result.error.message : String(result.error);
442
509
  this.problems.push({ wilma: this.labelOf(result.index), message });
510
+ this.failures.set(result.index, message);
443
511
  continue;
444
512
  }
445
513
  worked += 1;
@@ -452,13 +520,21 @@ export class WilmaAccess {
452
520
  account.knownStudents.map((s) => ({ ...s, href: `/!${s.studentNumber}/` }))
453
521
  : // Accounts that list no students still work against the default page.
454
522
  [{ studentNumber: account.profile.studentNumber ?? "", name: "", href: "/" }];
523
+ this.listed.set(result.index, list);
455
524
  for (const student of list) {
456
- // Two guardians' logins on the same Wilma list the same children once.
457
- const key = `${account.profile.baseUrl}|${student.studentNumber}`;
458
- if (seen.has(key))
525
+ if (account.hidden?.includes(student.studentNumber))
459
526
  continue;
460
- seen.add(key);
461
- all.push({ ...student, account: result.index });
527
+ // Two logins on the same Wilma (two guardians, or a guardian and the child) list the same child once.
528
+ const key = `${account.profile.baseUrl.replace(/\/$/, "").toLowerCase()}|${student.studentNumber}`;
529
+ const at = seen.get(key);
530
+ if (at === undefined) {
531
+ seen.set(key, all.length);
532
+ all.push({ ...student, account: result.index });
533
+ }
534
+ else if (all[at].role === "student" && student.role === "guardian") {
535
+ // A guardian's login over the child's own, whichever logged in last: the parent's view (messages to guardians, invitations).
536
+ all[at] = { ...student, account: result.index };
537
+ }
462
538
  }
463
539
  }
464
540
  if (!worked)
@@ -466,9 +542,46 @@ export class WilmaAccess {
466
542
  this.studentsCache = all;
467
543
  return all;
468
544
  }
469
- /** The children as tools and commands show them (with their Wilma when there are several). */
545
+ /**
546
+ * The children as tools and commands show them (with their Wilma when there
547
+ * are several), and whose login reads each: a guardian's, or the student's own.
548
+ */
470
549
  async studentList() {
471
- return (await this.students()).map((s) => this.ref(s));
550
+ return (await this.students()).map((s) => ({ ...this.ref(s), ...(s.role ? { role: s.role } : {}) }));
551
+ }
552
+ /**
553
+ * Each login: its Wilma, username, whose it is (a guardian's, or a
554
+ * student's own; missing when Wilma doesn't say) and the children it lists.
555
+ * A child another login on the same Wilma also lists is read through one of
556
+ * them only; `readVia` names the other login's username. A child unticked
557
+ * on the login page has `hidden: true` and isn't read.
558
+ */
559
+ async logins() {
560
+ const students = await this.students();
561
+ return this.accounts.map((account, index) => {
562
+ const listed = (this.listed.get(index) ?? []).filter((s) => s.name);
563
+ const role = roleOf(listed);
564
+ return {
565
+ wilma: this.labelOf(index),
566
+ username: account.profile.username,
567
+ ...(role ? { role } : {}),
568
+ children: listed.map((s) => {
569
+ if (account.hidden?.includes(s.studentNumber))
570
+ return { name: s.name, studentNumber: s.studentNumber, hidden: true };
571
+ const read = students.find((x) => x.studentNumber === s.studentNumber && this.sameWilma(x.account, index));
572
+ return {
573
+ name: s.name,
574
+ studentNumber: s.studentNumber,
575
+ ...(read && read.account !== index ? { readVia: this.accounts[read.account].profile.username } : {}),
576
+ };
577
+ }),
578
+ ...(this.failures.has(index) ? { problem: this.failures.get(index) } : {}),
579
+ };
580
+ });
581
+ }
582
+ sameWilma(a, b) {
583
+ const url = (i) => this.accounts[i].profile.baseUrl.replace(/\/$/, "").toLowerCase();
584
+ return url(a) === url(b);
472
585
  }
473
586
  async selectStudents(student) {
474
587
  const students = await this.students();
@@ -514,9 +627,11 @@ export class WilmaAccess {
514
627
  }
515
628
  async schedule(opts = {}) {
516
629
  const selection = resolveScheduleDateSelection(opts);
517
- const range = selection.startDate === selection.endDate
518
- ? { date: selection.startDate }
519
- : { weekStart: selection.startDate, weekEnd: selection.endDate };
630
+ const range = selection.outputWhen === "period"
631
+ ? { from: selection.startDate, to: selection.endDate }
632
+ : selection.startDate === selection.endDate
633
+ ? { date: selection.startDate }
634
+ : { weekStart: selection.startDate, weekEnd: selection.endDate };
520
635
  const result = await this.perStudent(opts.student, async (client) => ({
521
636
  lessons: await client.schedule.list({ from: selection.startDate, to: selection.endDate }),
522
637
  }));
@@ -533,7 +648,29 @@ export class WilmaAccess {
533
648
  return { exams: (await client.exams.upcoming()).slice(0, opts.limit ?? 20) };
534
649
  });
535
650
  }
651
+ /**
652
+ * Exam grades: the newest the front page lists, or with `from` (to `to`,
653
+ * default today) Wilma's list of past exams for that period (Kokeet >
654
+ * Menneet), including exams still waiting for a grade (grade: null).
655
+ */
536
656
  async grades(opts = {}) {
657
+ if (opts.to && !opts.from)
658
+ throw new WilmaAiError("invalid_argument", "A period needs a start date (from) as well.");
659
+ if (opts.from) {
660
+ const from = parseIsoDate(opts.from);
661
+ const to = opts.to ? parseIsoDate(opts.to) : todayString();
662
+ if (to < from)
663
+ throw new WilmaAiError("invalid_argument", `The period ends (${to}) before it starts (${from}).`);
664
+ return {
665
+ from,
666
+ to,
667
+ ...(await this.perStudent(opts.student, async (client) => {
668
+ const exams = await client.exams.past({ from, to });
669
+ // Every exam of the period unless a limit is given.
670
+ return { grades: opts.limit ? exams.slice(0, opts.limit) : exams };
671
+ })),
672
+ };
673
+ }
537
674
  return this.perStudent(opts.student, async (client) => {
538
675
  const overview = await client.overview.get();
539
676
  return { grades: overview.grades.slice(0, opts.limit ?? 20) };
@@ -567,6 +704,47 @@ export class WilmaAccess {
567
704
  async gradebook(opts = {}) {
568
705
  return this.perStudent(opts.student, async (client) => ({ gradebook: await client.gradebook.get() }));
569
706
  }
707
+ /**
708
+ * The child's class, class teacher and other supervisors (front page), and
709
+ * their own teachers (Wilma's recipient list) with what each teaches them,
710
+ * from this and next week's timetable. Parts Wilma can't give are listed in
711
+ * `unavailable`.
712
+ */
713
+ async contacts(opts = {}) {
714
+ const [from] = currentWeekBounds(0);
715
+ const [, to] = currentWeekBounds(1);
716
+ return this.perStudent(opts.student, async (client) => {
717
+ const unavailable = [];
718
+ const optional = (name, promise, fallback) => promise.catch((err) => {
719
+ if (err instanceof NetworkError)
720
+ throw err;
721
+ unavailable.push(name);
722
+ return fallback;
723
+ });
724
+ const [details, recipients, lessons] = await Promise.all([
725
+ client.student.get(),
726
+ optional("teachers", client.messages.recipients(), []),
727
+ // Only for what each teacher teaches.
728
+ client.schedule.list({ from, to }).catch((err) => {
729
+ if (err instanceof NetworkError)
730
+ throw err;
731
+ return [];
732
+ }),
733
+ ]);
734
+ const subjects = subjectsByTeacher(lessons);
735
+ return {
736
+ contacts: {
737
+ className: details.className,
738
+ supervisors: details.supervisors.map((s) => ({ name: s.name, role: s.role })),
739
+ teachers: ownTeachers(recipients).map((r) => {
740
+ const code = codeInName(r.name);
741
+ return { name: r.name, subjects: (code && subjects.get(code.toUpperCase())) || [] };
742
+ }),
743
+ ...(unavailable.length ? { unavailable } : {}),
744
+ },
745
+ };
746
+ });
747
+ }
570
748
  async printouts(opts = {}) {
571
749
  return this.perStudent(opts.student, async (client) => ({ printouts: await client.printouts.list() }));
572
750
  }
@@ -585,7 +763,8 @@ export class WilmaAccess {
585
763
  }
586
764
  async messages(opts = {}) {
587
765
  return this.perStudent(opts.student, async (client) => {
588
- const messages = await client.messages.list(opts.folder ?? "inbox");
766
+ // Invitations come with the family's answer as Wilma lists it (the same request).
767
+ const messages = opts.folder === "appointments" ? (await client.invitations.list()).map(listedInvitation) : await client.messages.list(opts.folder ?? "inbox");
589
768
  return { messages: messages.slice(0, opts.limit ?? 20) };
590
769
  });
591
770
  }
package/dist/cli-args.js CHANGED
@@ -7,6 +7,7 @@ const DATA_COMMANDS = [
7
7
  "exams",
8
8
  "grades",
9
9
  "gradebook",
10
+ "contacts",
10
11
  "notes",
11
12
  "messages",
12
13
  "news",
package/dist/cli-help.js CHANGED
@@ -8,10 +8,13 @@ const COMMAND_HELP = {
8
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
9
  },
10
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 }] }] }",
11
+ usage: [
12
+ `wilma schedule [today|tomorrow|week|next-week|<YYYY-MM-DD>|<weekday>] [${STUDENT}]`,
13
+ `wilma schedule --from <date> [--to <date>] [${STUDENT}]`,
14
+ ],
15
+ 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. --from reads any other week or period (to the Friday of that week unless --to is given; at most 31 days).",
16
+ examples: ["wilma schedule tomorrow", "wilma schedule next-week", "wilma schedule 2026-10-07", "wilma schedule thu --student Kiia", "wilma schedule --from 2026-11-16"],
17
+ output: "{ when, date | weekStart+weekEnd | from+to, students: [{ student, lessons: [{ date, start, end, subject, teacher, room }] }] }",
15
18
  },
16
19
  homework: {
17
20
  usage: [`wilma homework [--limit 10] [${STUDENT}]`],
@@ -25,9 +28,16 @@ const COMMAND_HELP = {
25
28
  output: "{ students: [{ student, exams: [{ date, time, subject, name, topic, teacher }] }] }",
26
29
  },
27
30
  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
+ usage: [`wilma grades [--limit 20] [${STUDENT}]`, `wilma grades --from <date> [--to <date>] [--limit N] [${STUDENT}]`],
32
+ about: "Recent exam grades from Wilma's front page. --from reads Wilma's list of past exams for a period instead (as far back as the school keeps them), including exams still waiting for a grade (grade: null). For course and report-card grades, see `wilma gradebook`.",
33
+ examples: ["wilma grades", "wilma grades --from 2026-08-01 --student Kiia"],
34
+ output: "{ [from, to,] students: [{ student, grades: [{ date, subject, name, grade, verbalGrade, info, teacher, seen }] }] }",
35
+ },
36
+ contacts: {
37
+ usage: [`wilma contacts [${STUDENT}]`],
38
+ about: "The child's class, class teacher and other supervisors (with their role as Wilma names it), and their own teachers with the subjects each teaches them in this and next week's timetable.",
39
+ examples: ["wilma contacts", "wilma contacts --student Kiia"],
40
+ output: "{ students: [{ student, contacts: { className, supervisors: [{ name, role }], teachers: [{ name, subjects }], unavailable? } }] }",
31
41
  },
32
42
  gradebook: {
33
43
  usage: [`wilma gradebook [${STUDENT}]`],
@@ -58,17 +68,17 @@ const COMMAND_HELP = {
58
68
  },
59
69
  students: {
60
70
  usage: ["wilma students"],
61
- about: "The children on the saved logins (number, name, and their Wilma when there are several).",
71
+ about: "The children on the saved logins (number, name, their Wilma when there are several, and role: whether a guardian's login or the student's own reads them).",
62
72
  examples: ["wilma students"],
63
73
  },
64
74
  login: {
65
75
  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.",
76
+ about: "Log in to Wilma, with a parent's account or a student's own. Opens a one-time login page in the browser where the user picks their school's Wilma; logins are saved on this computer. The page shows every saved Wilma with its children: families with children on different Wilmas add each one there, untick a child Wilma lists twice, log in again or remove a Wilma. Without a browser, pass --tenant and --username with the password on stdin or in WILMA_PASSWORD.",
67
77
  examples: ["wilma login"],
68
78
  },
69
79
  accounts: {
70
80
  usage: ["wilma accounts", "wilma accounts remove <number|Wilma name|username>"],
71
- about: "Saved Wilma logins, numbered (one per Wilma the children use). `remove` deletes one and ends its Wilma session; the other logins keep theirs.",
81
+ about: "Saved Wilma logins, numbered (one per Wilma the children use), with role \"student\" for a student's own login, and under hidden the children unticked on the login page (not read). `remove` deletes one and ends its Wilma session; the other logins keep theirs. When a parent's and a child's own login on the same Wilma are both saved, commands read that child through the parent's.",
72
82
  examples: ["wilma accounts", "wilma accounts remove 2"],
73
83
  },
74
84
  logout: {
@@ -98,14 +108,15 @@ const COMMAND_HELP = {
98
108
  };
99
109
  const OVERVIEW = [
100
110
  ["summary", "daily briefing for every child (start here)"],
101
- ["schedule [when]", "lessons: today, tomorrow, week, next-week, a date or a weekday"],
111
+ ["schedule [when]", "lessons: today, tomorrow, week, next-week, a date, a weekday, or --from <date>"],
102
112
  ["exams", "upcoming exams with topics and start times"],
103
113
  ["homework", "recent homework"],
104
114
  ["notes [summary]", "lesson notes: absences and teachers' feedback; summary counts them"],
105
115
  ["messages [id]", "messages, or one with its replies"],
106
116
  ["news [id [download r]]", "bulletins, one bulletin, or a linked file"],
107
- ["grades", "recent exam grades"],
117
+ ["grades", "exam grades: recent, or --from <date> for a period"],
108
118
  ["gradebook", "course and report-card grades"],
119
+ ["contacts", "class, class teacher and teachers"],
109
120
  ["printouts [id]", "PDFs such as report cards"],
110
121
  ["students", "the children"],
111
122
  ["login", "log in (opens a page in the browser)"],
@@ -1,6 +1,9 @@
1
1
  import type { Message, NewsItem } from "@wilm-ai/wilma-client";
2
2
  export declare function compactText(value: string | null | undefined): string;
3
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;
4
+ export declare function messageLine(msg: Pick<Message, "wilmaId" | "subject" | "sentAt" | "senderName" | "unread" | "replyCount"> & {
5
+ invitationStatus?: string | null;
6
+ invitationInfo?: string | null;
7
+ }): string;
5
8
  /** Text for a command's result. */
6
9
  export declare function formatText(command: string, action: string | null, result: any): string;
@@ -62,7 +62,9 @@ export function newsLine(item) {
62
62
  export function messageLine(msg) {
63
63
  const sender = msg.senderName ? ` — ${compactText(msg.senderName)}` : "";
64
64
  const replies = msg.replyCount ? ` [${msg.replyCount} ${msg.replyCount === 1 ? "reply" : "replies"}]` : "";
65
- return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies} (id ${msg.wilmaId})`;
65
+ // An invitation's answer in Wilma's own word ("Applied 29.05.2026 13:00").
66
+ const answer = msg.invitationStatus ? ` [${compactText([msg.invitationStatus, msg.invitationInfo].filter(Boolean).join(" "))}]` : "";
67
+ return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies}${answer} (id ${msg.wilmaId})`;
66
68
  }
67
69
  function lessonNoteLine(note, withDate) {
68
70
  const date = withDate ? `${note.date} ` : "";
@@ -116,15 +118,27 @@ export function formatText(command, action, result) {
116
118
  case "summary":
117
119
  return perStudent(result, "summary", (s) => summaryLines(s));
118
120
  case "schedule": {
119
- const period = result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
121
+ const period = result.from ? `${result.from} – ${result.to}` : result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
120
122
  return titled(`Schedule ${period}`, result, perStudent(result, "lessons", scheduleLines));
121
123
  }
122
124
  case "homework":
123
125
  return perStudent(result, "homework", (items) => items.length ? items.map((h) => ` ${h.date} ${h.subject}: ${compactText(h.homework)}`) : [" No recent homework."]);
124
126
  case "exams":
125
127
  return perStudent(result, "exams", (items) => (items.length ? items.map((e) => ` ${examLine(e)}`) : [" No upcoming exams."]));
126
- case "grades":
127
- return perStudent(result, "grades", (items) => items.length ? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade}`) : [" No exam grades."]);
128
+ case "grades": {
129
+ const lines = (items) => items.length
130
+ ? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade ?? "no grade yet"}${g.verbalGrade ? ` (${compactText(g.verbalGrade)})` : ""}`)
131
+ : [result.from ? " No exams in this period." : " No exam grades."];
132
+ const grades = perStudent(result, "grades", lines);
133
+ return result.from ? titled(`Exams ${result.from} – ${result.to}`, result, grades) : grades;
134
+ }
135
+ case "contacts":
136
+ return perStudent(result, "contacts", (c) => [
137
+ ` Class: ${c.className ?? "not shown"}`,
138
+ ...c.supervisors.map((s) => ` ${compactText(s.role)}: ${s.name}`),
139
+ ...(c.teachers.length ? [" Teachers:", ...c.teachers.map((t) => ` ${t.name}${t.subjects.length ? ` — ${t.subjects.join(", ")}` : ""}`)] : []),
140
+ ...(c.unavailable?.length ? [` (Not available from Wilma right now: ${c.unavailable.join(", ")})`] : []),
141
+ ]);
128
142
  case "gradebook":
129
143
  return perStudent(result, "gradebook", (entries) => gradebookLines(entries));
130
144
  case "notes": {
@@ -178,7 +192,7 @@ export function formatText(command, action, result) {
178
192
  return perStudent(result, "printouts", (items) => items.length ? [...items.map((p) => ` ${compactText(p.title)} (id ${p.id})`)] : [" No printouts."]);
179
193
  case "students":
180
194
  return result.students
181
- .map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}`)
195
+ .map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}${s.role === "student" ? " [own login]" : ""}`)
182
196
  .join("\n");
183
197
  default:
184
198
  return JSON.stringify(result, null, 2);
package/dist/config.d.ts CHANGED
@@ -5,14 +5,21 @@ export interface StoredProfile {
5
5
  username: string;
6
6
  passwordObfuscated: string;
7
7
  totpSecretObfuscated?: string | null;
8
- students?: {
9
- studentNumber: string;
10
- name: string;
11
- }[];
8
+ /** The children the login lists; `role` says whose login it is for each (a guardian's, or the student's own). */
9
+ students?: SavedStudent[];
10
+ /** Student numbers of children unticked on the login page (a profile Wilma lists twice): not read. */
11
+ hidden?: string[];
12
12
  lastStudentNumber?: string | null;
13
13
  lastStudentName?: string | null;
14
14
  lastUsedAt: string;
15
15
  }
16
+ export interface SavedStudent {
17
+ studentNumber: string;
18
+ name: string;
19
+ role?: "guardian" | "student";
20
+ }
21
+ /** A child as saved with a login: the number, the name, and the role when Wilma says it. */
22
+ export declare function savedStudent(s: SavedStudent): SavedStudent;
16
23
  export interface CliConfig {
17
24
  profiles: StoredProfile[];
18
25
  lastProfileId?: string | null;
package/dist/config.js CHANGED
@@ -2,6 +2,10 @@ import { chmod, mkdir, readFile, rename, stat, writeFile, rm } from "node:fs/pro
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ /** A child as saved with a login: the number, the name, and the role when Wilma says it. */
6
+ export function savedStudent(s) {
7
+ return { studentNumber: s.studentNumber, name: s.name, ...(s.role ? { role: s.role } : {}) };
8
+ }
5
9
  const SALT = "wilmai::";
6
10
  export function getConfigPath() {
7
11
  const override = process.env.WILMAI_CONFIG_PATH;
@@ -1,5 +1,5 @@
1
1
  import { AuthenticationError, MfaRequiredError, WilmaClient, } from "@wilm-ai/wilma-client";
2
- import { obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
2
+ import { obfuscateSecret, revealSecret, saveConfig, savedStudent, } from "./config.js";
3
3
  import { generateTOTP, parseTotpSecret, TOTP_PERIOD_MS, totpCounter } from "./totp.js";
4
4
  import { normalizeTenantUrl, resolveTenant } from "./tenant-search.js";
5
5
  /**
@@ -174,7 +174,7 @@ export async function saveLogin(config, input) {
174
174
  username: input.username,
175
175
  passwordObfuscated: obfuscateSecret(input.password),
176
176
  totpSecretObfuscated: input.totpSecret ? obfuscateSecret(input.totpSecret.trim()) : previous?.totpSecretObfuscated ?? null,
177
- students: input.students.map((s) => ({ studentNumber: s.studentNumber, name: s.name })),
177
+ students: input.students.map(savedStudent),
178
178
  lastStudentNumber: lastStudent?.studentNumber ?? null,
179
179
  lastStudentName: lastStudent?.name ?? null,
180
180
  lastUsedAt: new Date().toISOString(),