@wilm-ai/wilma-client 1.6.0 → 1.7.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.
Files changed (42) hide show
  1. package/README.md +48 -1
  2. package/dist/client.d.ts +76 -4
  3. package/dist/client.js +114 -9
  4. package/dist/core.d.ts +16 -0
  5. package/dist/core.js +11 -0
  6. package/dist/downloads.d.ts +11 -0
  7. package/dist/downloads.js +21 -0
  8. package/dist/external-fetch.d.ts +2 -8
  9. package/dist/external-fetch.js +2 -6
  10. package/dist/index.d.ts +3 -11
  11. package/dist/index.js +14 -9
  12. package/dist/internal.d.ts +3 -0
  13. package/dist/internal.js +2 -0
  14. package/dist/parsers/attendance.d.ts +7 -2
  15. package/dist/parsers/attendance.js +16 -6
  16. package/dist/parsers/exams.d.ts +10 -1
  17. package/dist/parsers/exams.js +91 -0
  18. package/dist/parsers/forms.d.ts +40 -0
  19. package/dist/parsers/forms.js +93 -0
  20. package/dist/parsers/html-text.d.ts +4 -3
  21. package/dist/parsers/html-text.js +41 -10
  22. package/dist/parsers/invitations.d.ts +13 -0
  23. package/dist/parsers/invitations.js +121 -0
  24. package/dist/parsers/messages.js +4 -0
  25. package/dist/parsers/news.js +8 -4
  26. package/dist/parsers/overview.js +1 -0
  27. package/dist/parsers/recipients.d.ts +16 -0
  28. package/dist/parsers/recipients.js +170 -0
  29. package/dist/parsers/schedule.js +7 -2
  30. package/dist/parsers/students.js +8 -2
  31. package/dist/session.d.ts +102 -10
  32. package/dist/session.js +241 -34
  33. package/dist/timeouts.d.ts +17 -2
  34. package/dist/timeouts.js +23 -14
  35. package/dist/transport-node.d.ts +2 -0
  36. package/dist/transport-node.js +19 -0
  37. package/dist/transport.d.ts +21 -0
  38. package/dist/transport.js +19 -0
  39. package/dist/types.d.ts +111 -0
  40. package/dist/write.d.ts +143 -0
  41. package/dist/write.js +427 -0
  42. package/package.json +10 -2
@@ -0,0 +1,170 @@
1
+ /*
2
+ * Who a parent can write to, and the child's class and supervisors.
3
+ *
4
+ * Seen 2026-10-07 (Helsinki, Wilma 2.36):
5
+ * - the child's front page has a "Huollettavasi tiedot" panel of
6
+ * `.margin-bottom-inline` blocks, each a `<strong>Label:</strong><br>` and
7
+ * its value: the name, the class, the class teacher and other supervisors
8
+ * (each followed by a message link `compose?r_teacher=<id>`), and links to
9
+ * message all supervisors or all the child's teachers;
10
+ * - `/messages/recipients` lists recipients as `button.recipient-block` with
11
+ * the compose page in `data-source`, grouped in panels (the child, the other
12
+ * guardians, all own teachers; staff and teachers per school tab). Disabled
13
+ * buttons (no data-source) can't be written to. "Omat opettajat" (own
14
+ * teachers) one by one loads from the panel link's data-source.
15
+ */
16
+ import * as cheerio from "cheerio";
17
+ const clean = (text) => text.replace(/\s+/g, " ").trim();
18
+ /** A compose link as a path on this Wilma, or null for anything else. */
19
+ export function composePath(href, pageUrl) {
20
+ if (!href)
21
+ return null;
22
+ try {
23
+ const page = new URL(pageUrl);
24
+ const url = new URL(href, pageUrl);
25
+ if (url.origin !== page.origin || !/^(\/![^/]+)?\/messages\/compose$/.test(url.pathname))
26
+ return null;
27
+ return url.pathname + url.search;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** What a compose link writes to, from its r_ parameters (a repeated or mixed one: several people). */
34
+ export function recipientKind(path) {
35
+ const keys = [...new URL(path, "https://wilma.invalid").searchParams.keys()].filter((k) => k.startsWith("r_"));
36
+ if (keys.length > 1)
37
+ return "group";
38
+ switch (keys[0]) {
39
+ case "r_teacher":
40
+ return "teacher";
41
+ case "r_personnel":
42
+ return "staff";
43
+ case "r_student":
44
+ return "student";
45
+ case "r_guardian":
46
+ return "guardian";
47
+ case "r_ownteachers":
48
+ case "r_groupteachers":
49
+ return "group";
50
+ default:
51
+ return "other";
52
+ }
53
+ }
54
+ /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
55
+ export function parseStudentDetailsHtml(html, pageUrl) {
56
+ const $ = cheerio.load(html);
57
+ const details = { name: null, className: null, supervisors: [], messageGroups: [] };
58
+ // The panel whose blocks carry labels in <strong>, with a class or a supervisor's message link.
59
+ const panel = $(".panel-body")
60
+ .filter((_, p) => $(p).find(".margin-bottom-inline > strong").length > 0)
61
+ .filter((_, p) => /luokka|klass|class/i.test($(p).find(".margin-bottom-inline > strong").text()) || $(p).find("a[href*='r_teacher']").length > 0)
62
+ .first();
63
+ if (!panel.length)
64
+ return details;
65
+ const group = clean(panel.children("h1, h2").first().text());
66
+ panel.find(".margin-bottom-inline").each((_, block) => {
67
+ const label = clean($(block).children("strong").first().text()).replace(/:$/, "");
68
+ if (!label)
69
+ return;
70
+ const entries = blockEntries($, block, pageUrl);
71
+ if (/^(nimi|namn|name)$/i.test(label))
72
+ details.name = entries[0]?.text ?? null;
73
+ else if (/^(luokka|klass|class)$/i.test(label))
74
+ details.className = entries[0]?.text ?? null;
75
+ else if (entries.some((e) => e.link) || /ohjaaja|valvoja|handledare|föreståndare|supervisor|teacher|tutor/i.test(label)) {
76
+ for (const entry of entries) {
77
+ const recipient = entry.link ? { name: entry.text, group, school: null, kind: recipientKind(entry.link), composePath: entry.link } : null;
78
+ details.supervisors.push({ name: entry.text, role: label, recipient });
79
+ }
80
+ }
81
+ });
82
+ panel.find("a.show-text[href], .margin-top a[href], .margin-top-inline a[href]").each((_, a) => {
83
+ const path = composePath($(a).attr("href"), pageUrl);
84
+ if (!path || details.messageGroups.some((g) => g.composePath === path))
85
+ return;
86
+ const name = clean($(a).text()) || clean($(a).attr("title") ?? "");
87
+ details.messageGroups.push({ name, group, school: null, kind: recipientKind(path), composePath: path });
88
+ });
89
+ return details;
90
+ }
91
+ /** A block's values: text up to each <br>, with the message link that follows a name. */
92
+ function blockEntries($, block, pageUrl) {
93
+ const entries = [];
94
+ let text = "";
95
+ let link = null;
96
+ const flush = () => {
97
+ const value = clean(text);
98
+ if (value)
99
+ entries.push({ text: value, link });
100
+ text = "";
101
+ link = null;
102
+ };
103
+ $(block)
104
+ .contents()
105
+ .each((_, node) => {
106
+ if (node.type === "text") {
107
+ text += node.data;
108
+ return;
109
+ }
110
+ const el = $(node);
111
+ const tag = node.tagName?.toLowerCase();
112
+ if (tag === "strong")
113
+ return;
114
+ if (tag === "br")
115
+ return flush();
116
+ const href = el.is("a") ? el.attr("href") : el.find("a[href]").first().attr("href");
117
+ const path = composePath(href, pageUrl);
118
+ if (path)
119
+ link = path;
120
+ else
121
+ text += el.text();
122
+ });
123
+ flush();
124
+ return entries;
125
+ }
126
+ /**
127
+ * The recipients page (`/messages/recipients`), or a list it loads (own
128
+ * teachers). Only recipients Wilma lets the parent write to are returned.
129
+ */
130
+ export function parseRecipientsHtml(html, pageUrl, defaults = {}) {
131
+ const $ = cheerio.load(html);
132
+ const tabNames = new Map();
133
+ $("a[href^='#'][data-toggle='tab'], .nav-tabs a[href^='#'], ul.nav a[href^='#']").each((_, a) => {
134
+ tabNames.set(($(a).attr("href") ?? "").slice(1), clean($(a).text()));
135
+ });
136
+ const out = [];
137
+ $(".recipient-block").each((_, button) => {
138
+ const el = $(button);
139
+ if (el.attr("disabled") !== undefined)
140
+ return;
141
+ const path = composePath(el.attr("data-source"), pageUrl);
142
+ if (!path || out.some((r) => r.composePath === path))
143
+ return;
144
+ const panel = el.closest(".panel");
145
+ const group = clean(panel.find(".panel-title").first().text()) || defaults.group || "";
146
+ const tab = el.closest(".tab-pane").attr("id");
147
+ const school = tab && tabNames.has(tab) && !/common/i.test(tab) ? tabNames.get(tab) : null;
148
+ out.push({ name: clean(el.text()), group, school, kind: recipientKind(path), composePath: path });
149
+ });
150
+ return out;
151
+ }
152
+ /** Lists the recipients page loads on demand (own teachers one by one): [panel title, path]. */
153
+ export function recipientListSources(html, pageUrl) {
154
+ const $ = cheerio.load(html);
155
+ const page = new URL(pageUrl);
156
+ return $("a.disclosure[data-source]")
157
+ .map((_, a) => {
158
+ try {
159
+ const url = new URL($(a).attr("data-source"), pageUrl);
160
+ if (url.origin !== page.origin || !/\/messages\/recipients\//.test(url.pathname))
161
+ return null;
162
+ return [[clean($(a).text()), url.pathname + url.search]];
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ })
168
+ .get()
169
+ .filter(Boolean);
170
+ }
@@ -98,7 +98,12 @@ export function parseTimetableJson(payload) {
98
98
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
99
99
  continue;
100
100
  for (const module of lesson.modules ?? []) {
101
- const subject = (module.caption ?? "").trim();
101
+ // The caption is the course code, sometimes followed by the name
102
+ // ("BI_ Biologia", or just "yRUB1.1" in upper secondary schools); the
103
+ // name alone is in courseName. Lessons without a course (lunch, a
104
+ // class teacher's hour) have only a caption.
105
+ const caption = (module.caption ?? "").trim();
106
+ const subject = (module.courseName ?? "").trim() || caption;
102
107
  const teachers = module.teachers ?? [];
103
108
  const rooms = (module.rooms ?? []).map((room) => (room.abbreviation ?? room.caption ?? "").trim()).filter(Boolean);
104
109
  lessons.push({
@@ -107,7 +112,7 @@ export function parseTimetableJson(payload) {
107
112
  start,
108
113
  end,
109
114
  subject,
110
- subjectCode: firstSubjectToken(subject),
115
+ subjectCode: firstSubjectToken(caption),
111
116
  teacher: teachers.map((t) => [t.lastname, t.firstname].filter(Boolean).join(" ")).join(", "),
112
117
  teacherCode: teachers.map((t) => t.abbreviation ?? "").filter(Boolean).join(", "),
113
118
  groupId: Number(module.id) || 0,
@@ -53,15 +53,21 @@ function roleName(rec, fallback) {
53
53
  }
54
54
  return fallback;
55
55
  }
56
- function collectStudent(students, studentNumber, name) {
56
+ function collectStudent(students, studentNumber, name, role) {
57
57
  if (!students.has(studentNumber)) {
58
58
  students.set(studentNumber, {
59
59
  studentNumber,
60
60
  name,
61
61
  href: `/!${studentNumber}/`,
62
+ ...(role ? { role } : {}),
62
63
  });
63
64
  }
64
65
  }
66
+ /** A guardian's role for a child, or a student's own login; anything else unknown. */
67
+ function studentRole(type) {
68
+ const value = typeof type === "string" ? type.toLowerCase() : "";
69
+ return value === "guardian" ? "guardian" : value === "student" ? "student" : undefined;
70
+ }
65
71
  /**
66
72
  * Parse GET /api/v1/accounts/me/roles.
67
73
  * Accepts `{ payload: AccountRole[] }` or a raw array.
@@ -98,7 +104,7 @@ export function parseStudentsFromAccountsRoles(data) {
98
104
  if (!name) {
99
105
  continue;
100
106
  }
101
- collectStudent(students, studentNumber, name);
107
+ collectStudent(students, studentNumber, name, studentRole(roleType(rec)));
102
108
  }
103
109
  return [...students.values()];
104
110
  }
package/dist/session.d.ts CHANGED
@@ -1,4 +1,42 @@
1
- import { type RequestInit, type Response } from "undici";
1
+ import { type WilmaTransport } from "./transport.js";
2
+ /** What a request may carry (a subset of fetch's RequestInit that every transport can send). */
3
+ export interface RequestInit {
4
+ method?: string;
5
+ headers?: Record<string, string | undefined> | Headers | [string, string][];
6
+ body?: string;
7
+ redirect?: "follow" | "manual";
8
+ signal?: AbortSignal;
9
+ }
10
+ export interface SessionOptions {
11
+ studentNumber?: string | null;
12
+ debug?: boolean;
13
+ /** How requests are sent (default: the transport set by the entry point; undici on Node). */
14
+ transport?: WilmaTransport;
15
+ /**
16
+ * Log in again by itself when Wilma ends the session (default true, with
17
+ * the credentials of the last login). Set false where a background login
18
+ * would be unwelcome — every login ends the parent's other Wilma sessions:
19
+ * requests then fail with SessionEndedError and the caller decides.
20
+ */
21
+ relogin?: boolean;
22
+ /** Where the WilmAI programs on one computer keep this login's session (see SharedSession). */
23
+ shared?: SharedSession;
24
+ }
25
+ /**
26
+ * The session of one Wilma login, kept where every WilmAI program on the
27
+ * computer finds it (the CLI, assistants' connectors, the desktop app).
28
+ * Wilma allows one live session per account, so programs that each log in
29
+ * keep ending each other's sessions. With a shared session, a client whose
30
+ * session Wilma ended first continues the newest one another program saved,
31
+ * and logs in only when that one has ended too; after its own login it saves
32
+ * the session for the others.
33
+ */
34
+ export interface SharedSession {
35
+ /** The newest saved session (exportState() format), or null. */
36
+ load(): Promise<string | null>;
37
+ /** Save this client's session (after it logged in). */
38
+ save(state: string): Promise<void>;
39
+ }
2
40
  export declare class AuthenticationError extends Error {
3
41
  constructor(message: string);
4
42
  }
@@ -10,26 +48,45 @@ export declare class APIError extends Error {
10
48
  status: number;
11
49
  constructor(message: string, status: number);
12
50
  }
51
+ /**
52
+ * Wilma ended the session (another login on the account, or it expired) and
53
+ * this client doesn't log in again by itself (no saved credentials, or
54
+ * `relogin: false`). Nothing was sent; log in again and retry.
55
+ */
56
+ export declare class SessionEndedError extends APIError {
57
+ constructor(message?: string);
58
+ }
59
+ /**
60
+ * What happened to a client's session, for a host that shows it (e.g. a
61
+ * sign-in history): "login" (this client logged in), "adopted" (Wilma had
62
+ * ended its session and it continued the one another WilmAI program saved),
63
+ * "ended" (Wilma ended it and the client may not, or could not, get a live
64
+ * one back by itself).
65
+ */
66
+ export type SessionEvent = "login" | "adopted" | "ended";
13
67
  export declare class WilmaSession {
14
68
  private baseUrl;
15
69
  private auth;
16
70
  private studentNumber?;
17
71
  private debug;
18
- constructor(baseUrl: string, opts?: {
19
- studentNumber?: string | null;
20
- debug?: boolean;
21
- });
72
+ constructor(baseUrl: string, opts?: SessionOptions);
73
+ /** The address of this Wilma, e.g. "https://example.inschool.fi". */
74
+ get origin(): string;
22
75
  /** The login's cookies, so another process can resume the session (see resumeState). */
23
76
  exportState(): string;
24
77
  /**
25
78
  * Continue a session saved by exportState() instead of logging in. If Wilma
26
79
  * has ended it meanwhile, the first request notices (401/403 or the login
27
- * page) and logs in again with these credentials. Returns false when the
28
- * state holds no session to resume.
80
+ * page) and logs in again with these credentials; without credentials it
81
+ * fails with SessionEndedError. Returns false when the state holds no
82
+ * session to resume.
29
83
  */
30
- resumeState(state: string, username: string, password: string): boolean;
84
+ resumeState(state: string, username?: string, password?: string): boolean;
31
85
  /** Called after every successful login (first or again). */
86
+ /** Share this login's session with the other WilmAI programs (see SharedSession). */
87
+ setShared(shared: SharedSession | undefined): void;
32
88
  onLogin(callback: (() => void) | undefined): void;
89
+ onSessionEvent(callback: ((event: SessionEvent) => void) | undefined): void;
33
90
  /** Used to answer the two-step verification challenge when the session has to log in again. */
34
91
  setMfaCallback(onMfa?: (formkey: string) => Promise<string>): void;
35
92
  /**
@@ -42,6 +99,18 @@ export declare class WilmaSession {
42
99
  getPrefixedPath(path: string): string;
43
100
  login(username: string, password: string): Promise<void>;
44
101
  submitMfaCode(formkey: string, otpCode: string): Promise<void>;
102
+ /**
103
+ * End this login's session in Wilma, as its "Log out" button does: the
104
+ * logout form on the front page posts its formkey to /logout. Never logs
105
+ * in; a session Wilma has already ended needs nothing. The session is
106
+ * shared with every per-student session made from this one, and afterwards
107
+ * none of them logs in again by itself (call login() for a new session).
108
+ * Returns true when a live session was ended, false when there was none.
109
+ * If Wilma can't be reached, throws and keeps the session, so logging out
110
+ * can be tried again.
111
+ */
112
+ logout(): Promise<boolean>;
113
+ private endSession;
45
114
  private getLoginFormFields;
46
115
  /**
47
116
  * An authenticated request to this Wilma. Follows redirects within Wilma
@@ -69,13 +138,36 @@ export declare class WilmaSession {
69
138
  * waits for a fresh code.
70
139
  */
71
140
  answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
72
- /** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
73
- private relogin;
141
+ /**
142
+ * A live session after Wilma ended this one, once for everyone sharing this
143
+ * login even if many requests notice at the same time: the newest session
144
+ * another program saved (no login), else a login when this client may log
145
+ * in again. Without either, SessionEndedError.
146
+ */
147
+ private recover;
148
+ /** Continue the session another program saved, if it differs from ours and Wilma still accepts it. */
149
+ private adoptShared;
150
+ /** After a login: tell the host (onLogin) and save the session for the other programs. */
151
+ private loggedInNow;
152
+ /** Changes whenever the session behind this client changes (a login, or another program's session continued). */
153
+ get sessionMark(): number;
74
154
  get(path: string, init?: RequestInit, opts?: {
75
155
  externalRedirect?: "error" | "return";
76
156
  timeoutMs?: number;
77
157
  }): Promise<Response>;
78
158
  post(path: string, init?: RequestInit): Promise<Response>;
159
+ /**
160
+ * Send a form that changes something in Wilma (a reply, a sign-up, an
161
+ * absence report): one POST, never repeated. Unlike request(), it doesn't
162
+ * log in again and retry when the session has ended — after a new login
163
+ * the form's key is stale, and a write must not be sent twice — it fails
164
+ * with SessionEndedError instead (nothing was sent; prepare the form
165
+ * again). Redirects within Wilma are followed; the answer is returned for
166
+ * the caller to check.
167
+ */
168
+ submit(path: string, form: URLSearchParams | Record<string, string> | [string, string][], opts?: {
169
+ headers?: Record<string, string>;
170
+ }): Promise<Response>;
79
171
  private getLoginToken;
80
172
  /**
81
173
  * One request to this Wilma, following redirects within Wilma hop by hop