@wilm-ai/wilma-client 1.8.0 → 1.10.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 CHANGED
@@ -50,6 +50,7 @@ Wilma allows one live session per account: a new login ends the previous one, an
50
50
  - **Log in once** and use `client.forStudent(number)` for each child; they share the login.
51
51
  - A session that Wilma ends (another login, or expiry) **logs in again by itself** on the next request — once, even when many requests notice at the same time, including the two-step verification step. A refused password stops further attempts, so a changed password can't lock the account.
52
52
  - To continue a session in another process, save `client.exportSession()` (treat it like the password) and later call `WilmaClient.resume(profile, saved, onMfa)`. `client.onLogin(callback)` tells you when a new session should be saved.
53
+ - The password for `resume()` can be a function, e.g. one reading a keychain: `WilmaClient.resume({ baseUrl, username, password: () => readFromKeychain() }, saved)`. It's called only when Wilma has ended the session and a new login is needed, never while the saved one works, and once for many requests. If it fails, the request fails with `CredentialsUnavailableError` (with `reason` and `cause` from your error) and the next request tries again: unlike a password Wilma refuses, it never stops the client for good.
53
54
  - `client.logout()` ends the session, as Wilma's own Log out does, so the parent can use Wilma in the browser. It never logs in: a session Wilma has already ended needs nothing, and it returns `false`. Afterwards the client and its `forStudent()` clients don't log in again by themselves. If Wilma can't be reached it throws and keeps the session, so it can be tried again.
54
55
 
55
56
  ### Any JavaScript runtime
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type SharedSession, type SessionEvent } from "./session.js";
1
+ import { type PasswordSource, type SharedSession, type SessionEvent } from "./session.js";
2
2
  import type { Exam, GradebookEntry, Invitation, InvitationListing, MessageRecipient, StudentDetails, LessonNote, LessonNoteSummary, Message, MessageFolder, NewsItem, NewsResource, OverviewData, PastExam, Printout, StudentInfo, UpcomingExam, WilmaProfile } from "./types.js";
3
3
  import type { WilmaTransport } from "./transport.js";
4
4
  export type MfaCallback = (formkey: string) => Promise<string>;
@@ -19,6 +19,10 @@ export interface ClientOptions {
19
19
  */
20
20
  sharedSession?: SharedSession;
21
21
  }
22
+ /** A profile for WilmaClient.resume(): the password may be a function called only for a new login. */
23
+ export type ResumeProfile = Omit<WilmaProfile, "password"> & {
24
+ password: PasswordSource;
25
+ };
22
26
  export declare class WilmaClient {
23
27
  private session;
24
28
  private constructor();
@@ -27,8 +31,13 @@ export declare class WilmaClient {
27
31
  * A client on a session saved earlier with exportSession(), without logging
28
32
  * in. If Wilma has ended that session, the first request logs in again by
29
33
  * itself. Returns null when the saved state has no session.
34
+ *
35
+ * The password may be a function (e.g. reading a keychain): it is called
36
+ * only when Wilma needs a new login, never while the saved session works.
37
+ * If it fails, the request fails with CredentialsUnavailableError and the
38
+ * next request tries again.
30
39
  */
31
- static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback, options?: ClientOptions): WilmaClient | null;
40
+ static resume(profile: ResumeProfile, state: string, onMfaRequired?: MfaCallback, options?: ClientOptions): WilmaClient | null;
32
41
  /**
33
42
  * A client on a saved session without any credentials: it never logs in.
34
43
  * When Wilma ends the session, requests fail with SessionEndedError. For
package/dist/client.js CHANGED
@@ -47,6 +47,11 @@ export class WilmaClient {
47
47
  * A client on a session saved earlier with exportSession(), without logging
48
48
  * in. If Wilma has ended that session, the first request logs in again by
49
49
  * itself. Returns null when the saved state has no session.
50
+ *
51
+ * The password may be a function (e.g. reading a keychain): it is called
52
+ * only when Wilma needs a new login, never while the saved session works.
53
+ * If it fails, the request fails with CredentialsUnavailableError and the
54
+ * next request tries again.
50
55
  */
51
56
  static resume(profile, state, onMfaRequired, options = {}) {
52
57
  const session = new WilmaSession(profile.baseUrl, {
package/dist/core.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export type { Exam, ExamGrade, GradebookEntry, HomeworkItem, Invitation, InvitationListing, InvitationSlot, InvitationSlotState, MessageRecipient, StudentDetails, LessonNote, LessonNoteSummary, Message, MessageReply, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, PastExam, Printout, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
2
2
  export { WilmaClient } from "./client.js";
3
- export type { MfaCallback, ClientOptions } from "./client.js";
4
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError } from "./session.js";
5
- export type { SessionOptions, RequestInit, SharedSession, SessionEvent } from "./session.js";
3
+ export type { MfaCallback, ClientOptions, ResumeProfile } from "./client.js";
4
+ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError, CredentialsUnavailableError } from "./session.js";
5
+ export type { SessionOptions, RequestInit, SharedSession, SessionEvent, PasswordSource } from "./session.js";
6
6
  export { setDefaultTransport } from "./transport.js";
7
7
  export type { WilmaTransport, WilmaRequest, WilmaReply } from "./transport.js";
8
8
  export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
package/dist/core.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { WilmaClient } from "./client.js";
2
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError } from "./session.js";
2
+ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError, CredentialsUnavailableError } from "./session.js";
3
3
  export { setDefaultTransport } from "./transport.js";
4
4
  export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
5
5
  export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError } from "./network-error.js";
@@ -23,6 +23,12 @@ export interface WilmaForm {
23
23
  }[];
24
24
  }[];
25
25
  textareas: string[];
26
+ /** The form's checkboxes, ticked or not (`fields` has only the ticked ones, as a browser sends them). */
27
+ checkboxes?: {
28
+ name: string;
29
+ value: string;
30
+ checked: boolean;
31
+ }[];
26
32
  }
27
33
  export declare class FormOutsideWilmaError extends Error {
28
34
  constructor();
@@ -18,6 +18,7 @@ export function readForm($, form, pageUrl) {
18
18
  const buttons = [];
19
19
  const selects = [];
20
20
  const textareas = [];
21
+ const checkboxes = [];
21
22
  form.find("input, select, textarea, button").each((_, el) => {
22
23
  const control = $(el);
23
24
  const name = control.attr("name");
@@ -50,14 +51,17 @@ export function readForm($, form, pageUrl) {
50
51
  buttons.push({ name, value, label: clean(control.text()) || value });
51
52
  }
52
53
  else if (type === "checkbox" || type === "radio") {
53
- if (control.attr("checked") !== undefined)
54
+ const checked = control.attr("checked") !== undefined;
55
+ if (type === "checkbox")
56
+ checkboxes.push({ name, value: control.attr("value") ?? "on", checked });
57
+ if (checked)
54
58
  fields.push([name, control.attr("value") ?? "on"]);
55
59
  }
56
60
  else if (type !== "file" && type !== "reset" && type !== "button") {
57
61
  fields.push([name, value]);
58
62
  }
59
63
  });
60
- return { action: wilmaPath(form.attr("action") ?? "", pageUrl), fields, buttons, selects, textareas };
64
+ return { action: wilmaPath(form.attr("action") ?? "", pageUrl), fields, buttons, selects, textareas, checkboxes };
61
65
  }
62
66
  /** The form key on a page (Wilma's anti-forgery token), preferring one outside the logout form. */
63
67
  export function pageFormkey($) {
@@ -3,6 +3,12 @@ import type { MessageRecipient, StudentDetails } from "../types.js";
3
3
  export declare function composePath(href: string | undefined, pageUrl: string): string | null;
4
4
  /** What a compose link writes to, from its r_ parameters (a repeated or mixed one: several people). */
5
5
  export declare function recipientKind(path: string): MessageRecipient["kind"];
6
+ /**
7
+ * One compose link to several recipients, made as Wilma's own "to all teachers" links are: every r_… parameter of
8
+ * theirs, each once, and for more than one person showrecipients=1&collatedreplies=1 (the recipients see who else
9
+ * got it and each other's replies, which the message form can still untick). The links must be one child's.
10
+ */
11
+ export declare function composeToAll(paths: string[]): string;
6
12
  /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
7
13
  export declare function parseStudentDetailsHtml(html: string, pageUrl: string): StudentDetails;
8
14
  /**
@@ -51,6 +51,32 @@ export function recipientKind(path) {
51
51
  return "other";
52
52
  }
53
53
  }
54
+ /**
55
+ * One compose link to several recipients, made as Wilma's own "to all teachers" links are: every r_… parameter of
56
+ * theirs, each once, and for more than one person showrecipients=1&collatedreplies=1 (the recipients see who else
57
+ * got it and each other's replies, which the message form can still untick). The links must be one child's.
58
+ */
59
+ export function composeToAll(paths) {
60
+ if (!paths.length)
61
+ throw new TypeError("No recipients");
62
+ if (paths.length === 1)
63
+ return paths[0];
64
+ const urls = paths.map((p) => new URL(p, "https://wilma.invalid"));
65
+ if (urls.some((u) => u.pathname !== urls[0].pathname))
66
+ throw new TypeError("The recipients are another child's");
67
+ const params = [];
68
+ for (const u of urls) {
69
+ for (const [k, v] of u.searchParams)
70
+ if (k.startsWith("r_") && !params.some(([a, b]) => a === k && b === v))
71
+ params.push([k, v]);
72
+ }
73
+ const out = new URLSearchParams(params);
74
+ if (params.length > 1 || recipientKind(`?${out}`) === "group") {
75
+ out.set("showrecipients", "1");
76
+ out.set("collatedreplies", "1");
77
+ }
78
+ return `${urls[0].pathname}?${out}`;
79
+ }
54
80
  /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
55
81
  export function parseStudentDetailsHtml(html, pageUrl) {
56
82
  const $ = cheerio.load(html);
package/dist/session.d.ts CHANGED
@@ -56,6 +56,24 @@ export declare class APIError extends Error {
56
56
  export declare class SessionEndedError extends APIError {
57
57
  constructor(message?: string);
58
58
  }
59
+ /**
60
+ * The password, or where to get it: a function called only when Wilma needs a
61
+ * new login (e.g. reading a keychain), not while a saved session works.
62
+ */
63
+ export type PasswordSource = string | (() => Promise<string>);
64
+ /**
65
+ * Wilma needed a new login and the password couldn't be had (the function
66
+ * given as the password failed: a keychain locked, declined or missing the
67
+ * entry). Not a refusal by Wilma, so it never stops the client for good;
68
+ * the next request tries again. `reason` and `cause` come from the failure.
69
+ */
70
+ export declare class CredentialsUnavailableError extends Error {
71
+ reason: string;
72
+ constructor(message: string, options?: {
73
+ reason?: string;
74
+ cause?: unknown;
75
+ });
76
+ }
59
77
  /**
60
78
  * What happened to a client's session, for a host that shows it (e.g. a
61
79
  * sign-in history): "login" (this client logged in), "adopted" (Wilma had
@@ -81,7 +99,7 @@ export declare class WilmaSession {
81
99
  * fails with SessionEndedError. Returns false when the state holds no
82
100
  * session to resume.
83
101
  */
84
- resumeState(state: string, username?: string, password?: string): boolean;
102
+ resumeState(state: string, username?: string, password?: PasswordSource): boolean;
85
103
  /** Called after every successful login (first or again). */
86
104
  /** Share this login's session with the other WilmAI programs (see SharedSession). */
87
105
  setShared(shared: SharedSession | undefined): void;
@@ -138,6 +156,13 @@ export declare class WilmaSession {
138
156
  * waits for a fresh code.
139
157
  */
140
158
  answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
159
+ /**
160
+ * The password for a new login. A function given as the password is called
161
+ * here, only now; its answer replaces it (as a login does). If it fails, the
162
+ * session is "ended by Wilma" (never "logged out", never failed for good)
163
+ * and the error is a CredentialsUnavailableError.
164
+ */
165
+ private resolvePassword;
141
166
  /**
142
167
  * A live session after Wilma ended this one, once for everyone sharing this
143
168
  * login even if many requests notice at the same time: the newest session
package/dist/session.js CHANGED
@@ -35,6 +35,20 @@ export class SessionEndedError extends APIError {
35
35
  this.name = "SessionEndedError";
36
36
  }
37
37
  }
38
+ /**
39
+ * Wilma needed a new login and the password couldn't be had (the function
40
+ * given as the password failed: a keychain locked, declined or missing the
41
+ * entry). Not a refusal by Wilma, so it never stops the client for good;
42
+ * the next request tries again. `reason` and `cause` come from the failure.
43
+ */
44
+ export class CredentialsUnavailableError extends Error {
45
+ reason;
46
+ constructor(message, options = {}) {
47
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
48
+ this.name = "CredentialsUnavailableError";
49
+ this.reason = options.reason ?? "unavailable";
50
+ }
51
+ }
38
52
  /** How long Wilma may go silent (before answering, or mid-body) before a request fails. */
39
53
  const REQUEST_TIMEOUT_MS = 30_000;
40
54
  const MAX_REDIRECTS = 10;
@@ -268,6 +282,9 @@ export class WilmaSession {
268
282
  const ended = holdsSession(auth.cookieJar, this.baseUrl) ? await this.endSession() : false;
269
283
  auth.cookieJar.removeAllCookiesSync();
270
284
  auth.loggedIn = false;
285
+ // Logged out on purpose: not "ended by Wilma", so the next request neither
286
+ // logs in again nor continues a session another program saved.
287
+ auth.endedByWilma = false;
271
288
  auth.username = undefined;
272
289
  auth.password = undefined;
273
290
  return ended;
@@ -444,6 +461,36 @@ export class WilmaSession {
444
461
  }
445
462
  }
446
463
  }
464
+ /**
465
+ * The password for a new login. A function given as the password is called
466
+ * here, only now; its answer replaces it (as a login does). If it fails, the
467
+ * session is "ended by Wilma" (never "logged out", never failed for good)
468
+ * and the error is a CredentialsUnavailableError.
469
+ */
470
+ async resolvePassword() {
471
+ const auth = this.auth;
472
+ const source = auth.password;
473
+ if (typeof source !== "function")
474
+ return source ?? "";
475
+ try {
476
+ const password = await source();
477
+ if (typeof password !== "string" || !password)
478
+ throw new Error("No password was returned");
479
+ // logout() may have cleared it meanwhile: then it stays cleared.
480
+ if (auth.password === source)
481
+ auth.password = password;
482
+ return password;
483
+ }
484
+ catch (err) {
485
+ auth.loggedIn = false;
486
+ auth.endedByWilma = true;
487
+ auth.onEvent?.("ended");
488
+ if (err instanceof CredentialsUnavailableError)
489
+ throw err;
490
+ const reason = typeof err?.reason === "string" ? err.reason : undefined;
491
+ throw new CredentialsUnavailableError(err instanceof Error ? err.message : String(err), { reason, cause: err });
492
+ }
493
+ }
447
494
  /**
448
495
  * A live session after Wilma ended this one, once for everyone sharing this
449
496
  * login even if many requests notice at the same time: the newest session
@@ -462,6 +509,9 @@ export class WilmaSession {
462
509
  auth.onEvent?.("ended");
463
510
  throw new SessionEndedError();
464
511
  }
512
+ // Get the password before changing anything: if it can't be had, the
513
+ // session stays "ended by Wilma" and the next request tries again.
514
+ const password = await this.resolvePassword();
465
515
  auth.loggedIn = false;
466
516
  // Start from a clean jar: a leftover session cookie must not make a
467
517
  // failed login look successful.
@@ -469,7 +519,7 @@ export class WilmaSession {
469
519
  if (this.debug)
470
520
  console.error("[wilmai] session cancelled or expired; logging in again");
471
521
  try {
472
- await this.login(auth.username, auth.password);
522
+ await this.login(auth.username, password);
473
523
  }
474
524
  catch (err) {
475
525
  if (err instanceof MfaRequiredError && auth.onMfa) {
package/dist/write.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { WilmaClient } from "./client.js";
2
2
  import { type WilmaForm } from "./parsers/forms.js";
3
+ export { composeToAll } from "./parsers/recipients.js";
3
4
  import type { Invitation, MessageRecipient } from "./types.js";
4
5
  export type { WilmaForm } from "./parsers/forms.js";
5
6
  export { FormOutsideWilmaError } from "./parsers/forms.js";
@@ -98,14 +99,23 @@ export interface MessageForm {
98
99
  readonly form: WilmaForm;
99
100
  readonly subjectField: string;
100
101
  readonly bodyField: string;
102
+ /**
103
+ * Whether the recipients see each other's replies (Wilma's CollatedReplies box, which makes the thread an open
104
+ * discussion), as Wilma's form has it; null when the form has no such box.
105
+ */
106
+ readonly collatedReplies?: boolean | null;
101
107
  }
102
108
  export declare function prepareMessage(client: WilmaClient, to: {
103
109
  teacherId: string | number;
104
110
  } | Pick<MessageRecipient, "composePath">): Promise<MessageForm>;
105
- /** Send the message (once). */
111
+ /**
112
+ * Send the message (once). `collatedReplies` ticks or clears Wilma's "recipients see each other's replies" box
113
+ * when the form has one; left out, the box stays as Wilma's form has it.
114
+ */
106
115
  export declare function sendMessage(client: WilmaClient, prepared: MessageForm, input: {
107
116
  subject: string;
108
117
  text: string;
118
+ collatedReplies?: boolean;
109
119
  }): Promise<void>;
110
120
  /**
111
121
  * Sign up for an invitation's time slot, or cancel the sign-up. This is what
package/dist/write.js CHANGED
@@ -26,6 +26,7 @@ import { sessionOf } from "./internal.js";
26
26
  import { pageErrors, pageFormkey, pageNotices, readForm, setField, wilmaPath } from "./parsers/forms.js";
27
27
  import { slotState } from "./parsers/invitations.js";
28
28
  import { composePath } from "./parsers/recipients.js";
29
+ export { composeToAll } from "./parsers/recipients.js";
29
30
  export { FormOutsideWilmaError } from "./parsers/forms.js";
30
31
  /** Wilma doesn't offer this here right now (no answer form, absence reports closed…). Nothing was sent. */
31
32
  export class NotOfferedError extends Error {
@@ -166,6 +167,7 @@ export async function sendReply(client, prepared, text) {
166
167
  return;
167
168
  throw new WriteUnconfirmedError("Wilma didn't show the answer as sent. Check the thread in Wilma before answering again.");
168
169
  }
170
+ const COLLATED = "CollatedReplies";
169
171
  export async function prepareMessage(client, to) {
170
172
  const session = sessionFor(client);
171
173
  let path;
@@ -185,9 +187,21 @@ export async function prepareMessage(client, to) {
185
187
  const compose = await composeForm(session, path);
186
188
  if (!compose.subjectField)
187
189
  throw new NotOfferedError("Wilma's message form has no subject field.");
188
- return { kind: "message", sessionMark: session.sessionMark, recipients: compose.recipients, form: compose.form, subjectField: compose.subjectField, bodyField: compose.bodyField };
190
+ const collated = compose.form.checkboxes?.find((c) => c.name === COLLATED);
191
+ return {
192
+ kind: "message",
193
+ sessionMark: session.sessionMark,
194
+ recipients: compose.recipients,
195
+ form: compose.form,
196
+ subjectField: compose.subjectField,
197
+ bodyField: compose.bodyField,
198
+ collatedReplies: collated ? collated.checked : null,
199
+ };
189
200
  }
190
- /** Send the message (once). */
201
+ /**
202
+ * Send the message (once). `collatedReplies` ticks or clears Wilma's "recipients see each other's replies" box
203
+ * when the form has one; left out, the box stays as Wilma's form has it.
204
+ */
191
205
  export async function sendMessage(client, prepared, input) {
192
206
  const session = sessionFor(client);
193
207
  if (prepared.kind !== "message")
@@ -197,7 +211,12 @@ export async function sendMessage(client, prepared, input) {
197
211
  if (!input.text.trim())
198
212
  throw new TypeError("The message is empty");
199
213
  const before = await sentState(client, null);
200
- let fields = setField(prepared.form.fields, prepared.subjectField, input.subject);
214
+ let fields = prepared.form.fields;
215
+ const box = prepared.form.checkboxes?.find((c) => c.name === COLLATED);
216
+ if (box && input.collatedReplies !== undefined) {
217
+ fields = input.collatedReplies ? setField(fields, box.name, box.value) : fields.filter(([name]) => name !== box.name);
218
+ }
219
+ fields = setField(fields, prepared.subjectField, input.subject);
201
220
  fields = withButton(setField(fields, prepared.bodyField, editorBody(prepared.form, input.text)), prepared.form, "addsavebtn");
202
221
  sameSession(session, prepared);
203
222
  const answer = await read(await session.submit(prepared.form.action, fields), "The message");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-client",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Unofficial client for Wilma, the Finnish school information system: login (with two-step verification), schedule, homework, exams, messages and news.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "tsc -p tsconfig.json",
41
41
  "lint": "tsc -p tsconfig.json --noEmit",
42
- "test": "pnpm run build && node test/login.mjs && node test/logout.mjs && node test/core.mjs && node test/write.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
42
+ "test": "pnpm run build && node test/login.mjs && node test/logout.mjs && node test/password-source.mjs && node test/core.mjs && node test/write.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
43
43
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
44
44
  "fixtures:anonymize": "node scripts/anonymize-fixture.mjs"
45
45
  },