@wilm-ai/wilma-cli 2.3.2 → 2.5.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.
@@ -1,6 +1,6 @@
1
1
  import { NetworkError, WilmaClient, addExamTimes, } from "@wilm-ai/wilma-client";
2
- import { createHash } from "node:crypto";
3
2
  import { fileNameFromResponse, readResponseCapped } from "./downloads.js";
3
+ import { fileLoginKeys } from "./session-keys.js";
4
4
  /**
5
5
  * An error with a stable code for agents and scripts (the CLI's JSON errors
6
6
  * and exit codes use it): e.g. "unknown_student", "invalid_argument".
@@ -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
  /* ------------------------------------------------------------------ */
@@ -247,39 +304,59 @@ export function useSessionStore(store) {
247
304
  * in only when that one has ended too. Each login ends the others, so this
248
305
  * keeps WilmAI's programs from logging each other out.
249
306
  */
250
- function rememberSession(key, client, opts) {
307
+ function rememberSession(keys, client, opts) {
251
308
  const store = sessionStore;
252
309
  if (!store)
253
310
  return;
254
311
  const save = (state) => {
255
- const saving = store.save(key, state).catch(() => { });
312
+ const saving = saveUnder(store, keys, state).catch(() => { });
256
313
  pendingSaves.add(saving);
257
314
  void saving.finally(() => pendingSaves.delete(saving));
258
315
  return saving;
259
316
  };
260
- client.useSharedSession({ load: () => store.load(key), save });
317
+ client.useSharedSession({ load: () => loadNewest(store, keys), save });
261
318
  // A resumed session is already saved (and another process may have saved a newer one since).
262
319
  if (opts.fresh)
263
320
  void save(client.exportSession());
264
321
  }
322
+ /** A login's session under any of its labels, the newest (a store without that falls back to the first found). */
323
+ async function loadNewest(store, keys) {
324
+ if (store.loadNewest)
325
+ return store.loadNewest(keys);
326
+ for (const key of keys) {
327
+ const state = await store.load(key);
328
+ if (state)
329
+ return state;
330
+ }
331
+ return null;
332
+ }
333
+ async function saveUnder(store, keys, state) {
334
+ if (store.saveAll)
335
+ return store.saveAll(keys, state);
336
+ for (const key of keys)
337
+ await store.save(key, state);
338
+ }
339
+ async function removeUnder(store, keys) {
340
+ if (store.removeAll)
341
+ return store.removeAll(keys);
342
+ for (const key of keys)
343
+ await store.remove(key);
344
+ }
265
345
  const pendingSaves = new Set();
266
- /** Identifies one Wilma account and password (a changed password starts fresh). */
267
- function poolKey(profile) {
268
- const { baseUrl, username, password } = profile;
269
- // The password is part of the key, so a changed password starts a fresh session.
270
- return createHash("sha256")
271
- .update(`${baseUrl.replace(/\/$/, "")}\n${username.toLowerCase()}\n${password}`)
272
- .digest("hex");
346
+ /** The keys of a login known by its password (file and environment logins): both labels, v1 in the pool. */
347
+ function defaultKeys(profile) {
348
+ return fileLoginKeys(profile.baseUrl, profile.username, profile.password);
273
349
  }
274
350
  /**
275
351
  * Keep a session that is already logged in — e.g. the one the login page just
276
352
  * verified — so the first tool call needs no second login (and, with two-step
277
- * verification, no second code).
353
+ * verification, no second code). `keys` are the saved login's (v2 for a
354
+ * keychain login); without them, today's keys from the profile.
278
355
  */
279
- export function adoptSession(profile, client) {
280
- const key = poolKey(profile);
281
- pool.set(key, { client: Promise.resolve(client), lastUsed: Date.now() });
282
- rememberSession(key, client, { fresh: true });
356
+ export function adoptSession(profileOrKeys, client) {
357
+ const keys = "sessionKeys" in profileOrKeys ? profileOrKeys : defaultKeys(profileOrKeys);
358
+ pool.set(keys.poolKey, { client: Promise.resolve(client), lastUsed: Date.now() });
359
+ rememberSession(keys.sessionKeys, client, { fresh: true });
283
360
  }
284
361
  /**
285
362
  * Finish saving sessions (call before a short-lived process exits). Only
@@ -291,38 +368,42 @@ export async function flushSessions() {
291
368
  await entry.client.catch(() => null);
292
369
  await Promise.all([...pendingSaves]);
293
370
  }
371
+ /** Drop this process's client for a login (after its secrets moved, so the next use starts from its new keys). */
372
+ export function dropPooledSession(keys) {
373
+ pool.delete(keys.poolKey);
374
+ }
294
375
  /**
295
376
  * End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
296
377
  * it, in this process and in the session store, so nothing continues it. The
297
378
  * login itself stays saved; the next command or tool call logs in again.
298
- * Never logs in. Returns true when a live session was ended, false when there
299
- * was none. If Wilma can't be reached, throws and keeps the saved session, so
300
- * logging out can be tried again.
379
+ * Never logs in (so it needs no password). Returns true when a live session
380
+ * was ended, false when there was none. If Wilma can't be reached, throws and
381
+ * keeps the saved session, so logging out can be tried again.
301
382
  */
302
- export async function logoutSession(profile) {
303
- const key = poolKey(profile);
304
- const entry = pool.get(key);
383
+ export async function logoutSession(profile, keys = defaultKeys(profile)) {
384
+ const entry = pool.get(keys.poolKey);
305
385
  // The session in use here (a login in progress finishes first) and the saved
306
386
  // one, which another process may have replaced since. At most one is live.
307
387
  const pooled = entry ? await entry.client.catch(() => null) : null;
308
388
  const pooledState = pooled?.exportSession();
309
- const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
389
+ const saved = sessionStore ? await loadNewest(sessionStore, keys.sessionKeys).catch(() => null) : null;
310
390
  let ended = pooled ? await pooled.logout() : false;
311
- if (pool.get(key) === entry)
312
- pool.delete(key);
391
+ if (pool.get(keys.poolKey) === entry)
392
+ pool.delete(keys.poolKey);
313
393
  if (saved && saved !== pooledState) {
314
394
  const resumed = WilmaClient.resume({ ...profile, studentNumber: null }, saved);
315
395
  if (resumed && (await resumed.logout()))
316
396
  ended = true;
317
397
  }
318
- await sessionStore?.remove(key);
398
+ if (sessionStore)
399
+ await removeUnder(sessionStore, keys.sessionKeys);
319
400
  return ended;
320
401
  }
321
402
  /** Forget one login's session without ending it in Wilma (when the login itself is removed). */
322
- export async function forgetSession(profile) {
323
- const key = poolKey(profile);
324
- pool.delete(key);
325
- await sessionStore?.remove(key);
403
+ export async function forgetSession(profile, keys = defaultKeys(profile)) {
404
+ pool.delete(keys.poolKey);
405
+ if (sessionStore)
406
+ await removeUnder(sessionStore, keys.sessionKeys);
326
407
  }
327
408
  function pooledSession(account) {
328
409
  const now = Date.now();
@@ -330,24 +411,26 @@ function pooledSession(account) {
330
411
  if (now - entry.lastUsed > POOL_IDLE_MS)
331
412
  pool.delete(key);
332
413
  }
333
- const key = poolKey(account.profile);
334
- let entry = pool.get(key);
414
+ const keys = account.keys ?? defaultKeys(account.profile);
415
+ let entry = pool.get(keys.poolKey);
335
416
  if (!entry) {
336
417
  const profile = { ...account.profile, studentNumber: null };
418
+ const lazy = account.password;
337
419
  const client = (async () => {
338
- // A saved session first; it logs in again by itself if Wilma has ended it.
339
- const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
340
- const resumed = saved ? WilmaClient.resume(profile, saved, account.mfa) : null;
341
- const client = resumed ?? (await WilmaClient.login(profile, account.mfa));
342
- rememberSession(key, client, { fresh: !resumed });
420
+ // A saved session first; it logs in again by itself if Wilma has ended it
421
+ // (reading a keychain login's password only then).
422
+ const saved = sessionStore ? await loadNewest(sessionStore, keys.sessionKeys).catch(() => null) : null;
423
+ const resumed = saved ? WilmaClient.resume({ ...profile, password: lazy ?? profile.password }, saved, account.mfa) : null;
424
+ const client = resumed ?? (await WilmaClient.login({ ...profile, password: lazy ? await lazy() : profile.password }, account.mfa));
425
+ rememberSession(keys.sessionKeys, client, { fresh: !resumed });
343
426
  return client;
344
427
  })();
345
428
  const created = { client, lastUsed: now };
346
429
  entry = created;
347
- pool.set(key, created);
430
+ pool.set(keys.poolKey, created);
348
431
  client.catch(() => {
349
- if (pool.get(key) === created)
350
- pool.delete(key);
432
+ if (pool.get(keys.poolKey) === created)
433
+ pool.delete(keys.poolKey);
351
434
  });
352
435
  }
353
436
  entry.lastUsed = now;
@@ -386,9 +469,19 @@ export function matchStudents(students, student) {
386
469
  }
387
470
  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
471
  }
472
+ /** Whose login it is: a student's own when Wilma lists only the student themself, a guardian's when it says so. */
473
+ export function roleOf(students) {
474
+ if (students.length && students.every((s) => s.role === "student"))
475
+ return "student";
476
+ return students.some((s) => s.role === "guardian") ? "guardian" : undefined;
477
+ }
389
478
  export class WilmaAccess {
390
479
  accounts;
391
480
  studentsCache = null;
481
+ /** The children each login lists (by login index), including those read through another login. */
482
+ listed = new Map();
483
+ /** Why a login failed (by login index), while others worked. */
484
+ failures = new Map();
392
485
  /** One logged-in session per Wilma login, shared by every child on it. */
393
486
  sessions = new Map();
394
487
  /** Logins that failed while other logins worked. */
@@ -431,7 +524,7 @@ export class WilmaAccess {
431
524
  }
432
525
  }));
433
526
  const all = [];
434
- const seen = new Set();
527
+ const seen = new Map();
435
528
  let firstError = null;
436
529
  let worked = 0;
437
530
  for (const result of fetched) {
@@ -440,6 +533,7 @@ export class WilmaAccess {
440
533
  firstError ??= result.error;
441
534
  const message = result.error instanceof Error ? result.error.message : String(result.error);
442
535
  this.problems.push({ wilma: this.labelOf(result.index), message });
536
+ this.failures.set(result.index, message);
443
537
  continue;
444
538
  }
445
539
  worked += 1;
@@ -452,13 +546,21 @@ export class WilmaAccess {
452
546
  account.knownStudents.map((s) => ({ ...s, href: `/!${s.studentNumber}/` }))
453
547
  : // Accounts that list no students still work against the default page.
454
548
  [{ studentNumber: account.profile.studentNumber ?? "", name: "", href: "/" }];
549
+ this.listed.set(result.index, list);
455
550
  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))
551
+ if (account.hidden?.includes(student.studentNumber))
459
552
  continue;
460
- seen.add(key);
461
- all.push({ ...student, account: result.index });
553
+ // Two logins on the same Wilma (two guardians, or a guardian and the child) list the same child once.
554
+ const key = `${account.profile.baseUrl.replace(/\/$/, "").toLowerCase()}|${student.studentNumber}`;
555
+ const at = seen.get(key);
556
+ if (at === undefined) {
557
+ seen.set(key, all.length);
558
+ all.push({ ...student, account: result.index });
559
+ }
560
+ else if (all[at].role === "student" && student.role === "guardian") {
561
+ // A guardian's login over the child's own, whichever logged in last: the parent's view (messages to guardians, invitations).
562
+ all[at] = { ...student, account: result.index };
563
+ }
462
564
  }
463
565
  }
464
566
  if (!worked)
@@ -466,9 +568,46 @@ export class WilmaAccess {
466
568
  this.studentsCache = all;
467
569
  return all;
468
570
  }
469
- /** The children as tools and commands show them (with their Wilma when there are several). */
571
+ /**
572
+ * The children as tools and commands show them (with their Wilma when there
573
+ * are several), and whose login reads each: a guardian's, or the student's own.
574
+ */
470
575
  async studentList() {
471
- return (await this.students()).map((s) => this.ref(s));
576
+ return (await this.students()).map((s) => ({ ...this.ref(s), ...(s.role ? { role: s.role } : {}) }));
577
+ }
578
+ /**
579
+ * Each login: its Wilma, username, whose it is (a guardian's, or a
580
+ * student's own; missing when Wilma doesn't say) and the children it lists.
581
+ * A child another login on the same Wilma also lists is read through one of
582
+ * them only; `readVia` names the other login's username. A child unticked
583
+ * on the login page has `hidden: true` and isn't read.
584
+ */
585
+ async logins() {
586
+ const students = await this.students();
587
+ return this.accounts.map((account, index) => {
588
+ const listed = (this.listed.get(index) ?? []).filter((s) => s.name);
589
+ const role = roleOf(listed);
590
+ return {
591
+ wilma: this.labelOf(index),
592
+ username: account.profile.username,
593
+ ...(role ? { role } : {}),
594
+ children: listed.map((s) => {
595
+ if (account.hidden?.includes(s.studentNumber))
596
+ return { name: s.name, studentNumber: s.studentNumber, hidden: true };
597
+ const read = students.find((x) => x.studentNumber === s.studentNumber && this.sameWilma(x.account, index));
598
+ return {
599
+ name: s.name,
600
+ studentNumber: s.studentNumber,
601
+ ...(read && read.account !== index ? { readVia: this.accounts[read.account].profile.username } : {}),
602
+ };
603
+ }),
604
+ ...(this.failures.has(index) ? { problem: this.failures.get(index) } : {}),
605
+ };
606
+ });
607
+ }
608
+ sameWilma(a, b) {
609
+ const url = (i) => this.accounts[i].profile.baseUrl.replace(/\/$/, "").toLowerCase();
610
+ return url(a) === url(b);
472
611
  }
473
612
  async selectStudents(student) {
474
613
  const students = await this.students();
@@ -514,9 +653,11 @@ export class WilmaAccess {
514
653
  }
515
654
  async schedule(opts = {}) {
516
655
  const selection = resolveScheduleDateSelection(opts);
517
- const range = selection.startDate === selection.endDate
518
- ? { date: selection.startDate }
519
- : { weekStart: selection.startDate, weekEnd: selection.endDate };
656
+ const range = selection.outputWhen === "period"
657
+ ? { from: selection.startDate, to: selection.endDate }
658
+ : selection.startDate === selection.endDate
659
+ ? { date: selection.startDate }
660
+ : { weekStart: selection.startDate, weekEnd: selection.endDate };
520
661
  const result = await this.perStudent(opts.student, async (client) => ({
521
662
  lessons: await client.schedule.list({ from: selection.startDate, to: selection.endDate }),
522
663
  }));
@@ -533,7 +674,29 @@ export class WilmaAccess {
533
674
  return { exams: (await client.exams.upcoming()).slice(0, opts.limit ?? 20) };
534
675
  });
535
676
  }
677
+ /**
678
+ * Exam grades: the newest the front page lists, or with `from` (to `to`,
679
+ * default today) Wilma's list of past exams for that period (Kokeet >
680
+ * Menneet), including exams still waiting for a grade (grade: null).
681
+ */
536
682
  async grades(opts = {}) {
683
+ if (opts.to && !opts.from)
684
+ throw new WilmaAiError("invalid_argument", "A period needs a start date (from) as well.");
685
+ if (opts.from) {
686
+ const from = parseIsoDate(opts.from);
687
+ const to = opts.to ? parseIsoDate(opts.to) : todayString();
688
+ if (to < from)
689
+ throw new WilmaAiError("invalid_argument", `The period ends (${to}) before it starts (${from}).`);
690
+ return {
691
+ from,
692
+ to,
693
+ ...(await this.perStudent(opts.student, async (client) => {
694
+ const exams = await client.exams.past({ from, to });
695
+ // Every exam of the period unless a limit is given.
696
+ return { grades: opts.limit ? exams.slice(0, opts.limit) : exams };
697
+ })),
698
+ };
699
+ }
537
700
  return this.perStudent(opts.student, async (client) => {
538
701
  const overview = await client.overview.get();
539
702
  return { grades: overview.grades.slice(0, opts.limit ?? 20) };
@@ -567,6 +730,47 @@ export class WilmaAccess {
567
730
  async gradebook(opts = {}) {
568
731
  return this.perStudent(opts.student, async (client) => ({ gradebook: await client.gradebook.get() }));
569
732
  }
733
+ /**
734
+ * The child's class, class teacher and other supervisors (front page), and
735
+ * their own teachers (Wilma's recipient list) with what each teaches them,
736
+ * from this and next week's timetable. Parts Wilma can't give are listed in
737
+ * `unavailable`.
738
+ */
739
+ async contacts(opts = {}) {
740
+ const [from] = currentWeekBounds(0);
741
+ const [, to] = currentWeekBounds(1);
742
+ return this.perStudent(opts.student, async (client) => {
743
+ const unavailable = [];
744
+ const optional = (name, promise, fallback) => promise.catch((err) => {
745
+ if (err instanceof NetworkError)
746
+ throw err;
747
+ unavailable.push(name);
748
+ return fallback;
749
+ });
750
+ const [details, recipients, lessons] = await Promise.all([
751
+ client.student.get(),
752
+ optional("teachers", client.messages.recipients(), []),
753
+ // Only for what each teacher teaches.
754
+ client.schedule.list({ from, to }).catch((err) => {
755
+ if (err instanceof NetworkError)
756
+ throw err;
757
+ return [];
758
+ }),
759
+ ]);
760
+ const subjects = subjectsByTeacher(lessons);
761
+ return {
762
+ contacts: {
763
+ className: details.className,
764
+ supervisors: details.supervisors.map((s) => ({ name: s.name, role: s.role })),
765
+ teachers: ownTeachers(recipients).map((r) => {
766
+ const code = codeInName(r.name);
767
+ return { name: r.name, subjects: (code && subjects.get(code.toUpperCase())) || [] };
768
+ }),
769
+ ...(unavailable.length ? { unavailable } : {}),
770
+ },
771
+ };
772
+ });
773
+ }
570
774
  async printouts(opts = {}) {
571
775
  return this.perStudent(opts.student, async (client) => ({ printouts: await client.printouts.list() }));
572
776
  }
@@ -585,7 +789,8 @@ export class WilmaAccess {
585
789
  }
586
790
  async messages(opts = {}) {
587
791
  return this.perStudent(opts.student, async (client) => {
588
- const messages = await client.messages.list(opts.folder ?? "inbox");
792
+ // Invitations come with the family's answer as Wilma lists it (the same request).
793
+ const messages = opts.folder === "appointments" ? (await client.invitations.list()).map(listedInvitation) : await client.messages.list(opts.folder ?? "inbox");
589
794
  return { messages: messages.slice(0, opts.limit ?? 20) };
590
795
  });
591
796
  }
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",
@@ -229,9 +230,17 @@ export function parseCliArgs(argv) {
229
230
  case "logout":
230
231
  return parsed(null, args);
231
232
  case "config":
232
- if (args[0] !== "clear" || args.length > 1)
233
- throw usage('Expected "wilma config clear".');
234
- return parsed("clear");
233
+ if (args[0] === "clear" && args.length === 1)
234
+ return parsed("clear");
235
+ if (args[0] === "secrets") {
236
+ const [, setting, ...extra] = args;
237
+ noMore(extra);
238
+ if (setting !== undefined && !["file", "auto", "keychain"].includes(setting)) {
239
+ throw usage('Expected "wilma config secrets [file|auto|keychain]".');
240
+ }
241
+ return parsed("secrets", setting === undefined ? [] : [setting]);
242
+ }
243
+ throw usage('Expected "wilma config clear" or "wilma config secrets [file|auto|keychain]".');
235
244
  case "find-school":
236
245
  if (!args.length)
237
246
  throw usage('Expected "wilma find-school <city or school>".');
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, 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. 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: {
@@ -94,18 +104,24 @@ const COMMAND_HELP = {
94
104
  examples: ["npx -y @wilm-ai/wilma-cli@2 mcp"],
95
105
  },
96
106
  update: { usage: ["wilma update"], about: "Update the CLI with npm.", examples: ["wilma update"] },
97
- config: { usage: ["wilma config clear"], about: "Delete every saved login and session on this computer.", examples: ["wilma config clear"] },
107
+ config: {
108
+ usage: ["wilma config clear", "wilma config secrets [file|auto|keychain]"],
109
+ about: "`clear` deletes every saved login and session on this computer (and their keychain entries). `secrets` shows where saved passwords are kept, or changes it: `file` (the default, WilmAI's own file readable only by you), `keychain` (the macOS Keychain, Windows Credential Manager or a Linux desktop's Secret Service; refuses to save anywhere else) or `auto` (the keychain where there is one, the file where there isn't). Changing it moves the saved logins without logging in again. WILMAI_SECRET_STORE sets where new logins go for one process.",
110
+ examples: ["wilma config secrets", "wilma config secrets auto", "wilma config secrets file", "wilma config clear"],
111
+ output: "secrets: { setting, envOverride, keychain: { kind, usable, problem }, logins: [{ wilma, username, store: \"file\" | \"keychain\", hasTotp }], moved? }",
112
+ },
98
113
  };
99
114
  const OVERVIEW = [
100
115
  ["summary", "daily briefing for every child (start here)"],
101
- ["schedule [when]", "lessons: today, tomorrow, week, next-week, a date or a weekday"],
116
+ ["schedule [when]", "lessons: today, tomorrow, week, next-week, a date, a weekday, or --from <date>"],
102
117
  ["exams", "upcoming exams with topics and start times"],
103
118
  ["homework", "recent homework"],
104
119
  ["notes [summary]", "lesson notes: absences and teachers' feedback; summary counts them"],
105
120
  ["messages [id]", "messages, or one with its replies"],
106
121
  ["news [id [download r]]", "bulletins, one bulletin, or a linked file"],
107
- ["grades", "recent exam grades"],
122
+ ["grades", "exam grades: recent, or --from <date> for a period"],
108
123
  ["gradebook", "course and report-card grades"],
124
+ ["contacts", "class, class teacher and teachers"],
109
125
  ["printouts [id]", "PDFs such as report cards"],
110
126
  ["students", "the children"],
111
127
  ["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;