@wilm-ai/wilma-cli 2.3.2 → 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.
- package/README.md +1 -1
- package/dist/access.d.ts +18 -0
- package/dist/access.js +48 -3
- package/dist/agent-data.d.ts +184 -14
- package/dist/agent-data.js +195 -16
- package/dist/cli-args.js +1 -0
- package/dist/cli-help.js +23 -12
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +11 -4
- package/dist/config.js +4 -0
- package/dist/credentials.js +2 -2
- package/dist/index.js +37 -42
- package/dist/login-server.d.ts +26 -6
- package/dist/login-server.js +444 -227
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +2 -14
- package/package.json +2 -2
package/dist/agent-data.js
CHANGED
|
@@ -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
|
/* ------------------------------------------------------------------ */
|
|
@@ -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
|
|
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
|
-
|
|
457
|
-
const key = `${account.profile.baseUrl}|${student.studentNumber}`;
|
|
458
|
-
if (seen.has(key))
|
|
525
|
+
if (account.hidden?.includes(student.studentNumber))
|
|
459
526
|
continue;
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
/**
|
|
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.
|
|
518
|
-
? {
|
|
519
|
-
:
|
|
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
|
-
|
|
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
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: {
|
|
@@ -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
|
|
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
|
|
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)"],
|
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;
|
package/dist/cli-output.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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;
|
package/dist/credentials.js
CHANGED
|
@@ -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(
|
|
177
|
+
students: input.students.map(savedStudent),
|
|
178
178
|
lastStudentNumber: lastStudent?.studentNumber ?? null,
|
|
179
179
|
lastStudentName: lastStudent?.name ?? null,
|
|
180
180
|
lastUsedAt: new Date().toISOString(),
|