@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.
- package/README.md +20 -3
- package/dist/access.d.ts +40 -1
- package/dist/access.js +105 -6
- package/dist/agent-data.d.ts +200 -21
- package/dist/agent-data.js +261 -56
- package/dist/cli-args.js +12 -3
- package/dist/cli-help.js +29 -13
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +36 -6
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +214 -108
- package/dist/login-server.d.ts +28 -6
- package/dist/login-server.js +468 -239
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +26 -15
- package/dist/secret-moves.d.ts +44 -0
- package/dist/secret-moves.js +297 -0
- package/dist/secret-store.d.ts +45 -0
- package/dist/secret-store.js +487 -0
- package/dist/session-keys.d.ts +19 -0
- package/dist/session-keys.js +26 -0
- package/dist/session-store.d.ts +16 -0
- package/dist/session-store.js +69 -0
- package/package.json +8 -3
package/dist/agent-data.js
CHANGED
|
@@ -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
|
|
113
|
-
throw new WilmaAiError("invalid_argument", "Use
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
267
|
-
function
|
|
268
|
-
|
|
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(
|
|
280
|
-
const
|
|
281
|
-
pool.set(
|
|
282
|
-
rememberSession(
|
|
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
|
|
299
|
-
* was none. If Wilma can't be reached, throws and
|
|
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
|
|
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.
|
|
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(
|
|
312
|
-
pool.delete(
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
334
|
-
let entry = pool.get(
|
|
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
|
-
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
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(
|
|
430
|
+
pool.set(keys.poolKey, created);
|
|
348
431
|
client.catch(() => {
|
|
349
|
-
if (pool.get(
|
|
350
|
-
pool.delete(
|
|
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
|
|
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
|
-
|
|
457
|
-
const key = `${account.profile.baseUrl}|${student.studentNumber}`;
|
|
458
|
-
if (seen.has(key))
|
|
551
|
+
if (account.hidden?.includes(student.studentNumber))
|
|
459
552
|
continue;
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
/**
|
|
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.
|
|
518
|
-
? {
|
|
519
|
-
:
|
|
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
|
-
|
|
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]
|
|
233
|
-
|
|
234
|
-
|
|
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: [
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
|
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.
|
|
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: {
|
|
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
|
|
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
|
|
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)"],
|
package/dist/cli-output.d.ts
CHANGED
|
@@ -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">
|
|
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;
|