@wilm-ai/wilma-client 1.6.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @wilm-ai/wilma-client
2
2
 
3
- A TypeScript client for **Wilma**, the Finnish school information system, as a parent (guardian) sees it: schedule, homework, exams and grades, the gradebook, lesson notes and absences, messages with their replies, bulletins and their attachments, and printouts. Read-only.
3
+ A TypeScript client for **Wilma**, the Finnish school information system, as a parent (guardian) sees it: schedule, homework, exams and grades, the gradebook, lesson notes and absences, messages with their replies, event invitations, bulletins and their attachments, and printouts. The main entry point only reads (and logs out); an experimental, separate entry point writes.
4
4
 
5
5
  It powers the [`wilma` CLI and MCP server](https://www.npmjs.com/package/@wilm-ai/wilma-cli) ([wilm.ai](https://wilm.ai)).
6
6
 
@@ -26,12 +26,16 @@ for (const student of await client.students()) {
26
26
  const kid = client.forStudent(student.studentNumber);
27
27
  const overview = await kid.overview.get(); // this week's lessons, homework, exams, exam grades
28
28
  const exams = await kid.exams.upcoming(); // with start times when the school gives one
29
+ const results = await kid.exams.past({ from: "2024-08-01" }); // past exams with grades and topics; default: this school year
29
30
  const lessons = await kid.schedule.list({ from: "2026-10-05", to: "2026-10-09" }); // teachers and rooms
30
31
  const notes = await kid.attendance.list({ from: "2026-09-01", to: "2026-09-30" }); // lesson notes, with the teacher's words
31
32
  const summary = await kid.attendance.summary(); // counts by kind for the school year
32
33
  const gradebook = await kid.gradebook.get(); // subject > syllabus > course, with grades and dates
33
34
  const messages = await kid.messages.list("inbox"); // unread and replyCount included
34
35
  const thread = await kid.messages.get(messages[0].wilmaId); // the message and its replies
36
+ const invitations = await kid.invitations.list(); // event invitations, with whether the family has answered
37
+ const details = await kid.student.get(); // class, class teacher and supervisors, with message links
38
+ const recipients = await kid.messages.recipients(); // who the parent can write to: own teachers, staff, the other guardian
35
39
  const news = await kid.news.list(); // dated, pinned and older bulletins
36
40
  const printouts = await kid.printouts.list(); // PDFs such as report cards
37
41
  }
@@ -46,6 +50,26 @@ Wilma allows one live session per account: a new login ends the previous one, an
46
50
  - **Log in once** and use `client.forStudent(number)` for each child; they share the login.
47
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.
48
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
+ - `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
+ ### Any JavaScript runtime
56
+
57
+ `@wilm-ai/wilma-client/core` is the same client without Node-only code, for a browser extension, a desktop app's web view or Node. It sends every request through a transport you give it: one function that makes a single HTTP exchange without following redirects and returns the status, headers, `Set-Cookie` values and body. Cookies, redirects and timeouts stay in the client.
58
+
59
+ ```ts
60
+ import { WilmaClient, SessionEndedError } from "@wilm-ai/wilma-client/core";
61
+
62
+ const client = await WilmaClient.login(profile, onMfa, { transport, relogin: false });
63
+ const saved = client.exportSession();
64
+ // Later, without keeping the password:
65
+ const again = WilmaClient.resumeSession(profile.baseUrl, saved, { transport });
66
+ ```
67
+
68
+ With `relogin: false` (and always with `resumeSession`), a session Wilma has ended isn't logged in again behind the parent's back: requests fail with `SessionEndedError`, and the app asks.
69
+
70
+ ### One session for every program on the computer
71
+
72
+ Programs that each log in end each other's sessions (Wilma keeps one per account). Pass `sharedSession: { load, save }` (or call `client.useSharedSession(...)`) with a place they all use, such as the `wilma` CLI's session file: when Wilma ends a client's session, it first continues the newest session another program saved, and logs in only when that one has ended too; after its own login it saves the session for the others. `client.onSessionEvent(event => …)` tells an app what happened: `"login"`, `"adopted"` (another program's session continued) or `"ended"`. A form prepared under one session is never posted under another (see Writing). Downloading files that bulletins link to on other sites needs the main (Node) entry point, which can guard those requests.
49
73
 
50
74
  ### Times
51
75
 
@@ -61,6 +85,29 @@ JSON wherever Wilma has it: the account's roles, the front page (overview), mess
61
85
  - Files linked from bulletins are fetched without the Wilma login and only from public internet addresses (private, loopback and cloud-metadata addresses are refused, also after DNS resolution).
62
86
  - Requests and downloads fail after a while without progress instead of hanging.
63
87
 
88
+ ## Writing (experimental)
89
+
90
+ `@wilm-ai/wilma-client/write` answers messages, writes to teachers, signs up for event invitations and reports absences. It's a separate entry point, so read-only tools such as the `wilma` CLI don't carry it.
91
+
92
+ ```ts
93
+ import { prepareAbsenceReport, sendAbsenceReport } from "@wilm-ai/wilma-client/write";
94
+
95
+ const kid = client.forStudent(studentNumber); // writes are per child
96
+ const form = await prepareAbsenceReport(kid); // reads Wilma's form; changes nothing
97
+ // form.date is the day Wilma will record (today until 12:00, then tomorrow); form.reasons are the school's.
98
+ await sendAbsenceReport(kid, form, { reason: form.reasons[0].value, text: "Fever" });
99
+ ```
100
+
101
+ - **Two steps, like Wilma's own pages.** `prepare…` reads Wilma's form; `send…` posts it once, as Wilma's page would: the fields Wilma filled in plus what the parent wrote (message bodies as HTML, since Wilma's message forms come with an editor). Also `prepareReply`/`sendReply` (an open discussion's quick reply, or a private answer to the sender), `prepareMessage`/`sendMessage` (to anyone from `messages.recipients()` or `student.get()`, or a teacher by id), and `answerInvitation(kid, messageId, slotId, "apply" | "cancel")`. Prepared forms say who a message goes to (`recipients`).
102
+ - **Never sent twice.** A send isn't repeated, and never retried after a login: if Wilma has ended the session it fails with `SessionEndedError`, and nothing was sent.
103
+ - **Checked afterwards.** Wilma's answer is checked, and the result read back where Wilma shows it (the thread, sent messages, the invitation). `WriteRefusedError`: Wilma said no and nothing changed. `WriteUnconfirmedError`: it may have gone through, so check Wilma before sending again. `NotOfferedError`: Wilma doesn't offer this here (no answer form, sign-up closed).
104
+
105
+ The forms follow Wilma's pages as seen in October 2026, and the tests prepare them from anonymised copies of the real pages. A new message and an answer in an open discussion have been sent on the real Wilma (October 2026). A private answer, signing up for an invitation and an absence report have been tested against a simulated Wilma only, so how they're confirmed may still need adjusting after the first real use. "Won't attend" on invitations isn't supported.
106
+
107
+ ### Not covered
108
+
109
+ Rarely used parts of Wilma stay there: applications and decisions (`/decisions`, e.g. leave from school), forms such as basic information and consents, surveys, assessment discussions, support plans, notification settings and course selection. Explaining absences ("Selvitä") waits until a real case shows its form. Students' own logins (Wilma for students, EDU) aren't supported; this is a guardian's client.
110
+
64
111
  ## License
65
112
 
66
113
  MIT
package/dist/client.d.ts CHANGED
@@ -1,20 +1,61 @@
1
- import type { Exam, GradebookEntry, LessonNote, LessonNoteSummary, Message, MessageFolder, NewsItem, NewsResource, OverviewData, Printout, StudentInfo, UpcomingExam, WilmaProfile } from "./types.js";
2
- import type { Response } from "undici";
1
+ import { type SharedSession, type SessionEvent } from "./session.js";
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
+ import type { WilmaTransport } from "./transport.js";
3
4
  export type MfaCallback = (formkey: string) => Promise<string>;
5
+ export interface ClientOptions {
6
+ /** How requests are sent (default: undici on Node; required with the "core" entry point unless a default is set). */
7
+ transport?: WilmaTransport;
8
+ /**
9
+ * Log in again by itself when Wilma ends the session (default true). Every
10
+ * login ends the parent's other Wilma sessions (browser, phone), so an app
11
+ * the parent is looking at may prefer false: requests then fail with
12
+ * SessionEndedError and the app asks.
13
+ */
14
+ relogin?: boolean;
15
+ /**
16
+ * Share the session with the other WilmAI programs on this computer (the
17
+ * CLI, connectors, a desktop app) instead of each logging in and ending the
18
+ * others' sessions. See SharedSession.
19
+ */
20
+ sharedSession?: SharedSession;
21
+ }
4
22
  export declare class WilmaClient {
5
23
  private session;
6
24
  private constructor();
7
- static login(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<WilmaClient>;
25
+ static login(profile: WilmaProfile, onMfaRequired?: MfaCallback, options?: ClientOptions): Promise<WilmaClient>;
8
26
  /**
9
27
  * A client on a session saved earlier with exportSession(), without logging
10
28
  * in. If Wilma has ended that session, the first request logs in again by
11
29
  * itself. Returns null when the saved state has no session.
12
30
  */
13
- static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback): WilmaClient | null;
31
+ static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback, options?: ClientOptions): WilmaClient | null;
32
+ /**
33
+ * A client on a saved session without any credentials: it never logs in.
34
+ * When Wilma ends the session, requests fail with SessionEndedError. For
35
+ * apps that don't keep the parent's password. Returns null when the saved
36
+ * state has no session.
37
+ */
38
+ static resumeSession(baseUrl: string, state: string, options?: Omit<ClientOptions, "relogin"> & {
39
+ debug?: boolean;
40
+ }): WilmaClient | null;
41
+ /** The Wilma this client talks to, e.g. "https://example.inschool.fi". */
42
+ get origin(): string;
14
43
  /** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
15
44
  exportSession(): string;
45
+ /**
46
+ * End this login's session in Wilma (its "Log out"), e.g. so the parent can
47
+ * use Wilma in the browser. Never logs in. Afterwards this client and its
48
+ * forStudent() clients don't log in again by themselves. Returns true when
49
+ * a live session was ended, false when there was none; throws (keeping the
50
+ * session) when Wilma can't be reached.
51
+ */
52
+ logout(): Promise<boolean>;
53
+ /** Share the session with the other WilmAI programs on this computer from now on (see ClientOptions.sharedSession). */
54
+ useSharedSession(shared: SharedSession | undefined): void;
16
55
  /** Run a callback after every successful login, e.g. to save the new session. */
17
56
  onLogin(callback: (() => void) | undefined): void;
57
+ /** Be told when this client logs in, continues another program's session, or finds its session ended (see SessionEvent). */
58
+ onSessionEvent(callback: ((event: SessionEvent) => void) | undefined): void;
18
59
  static listStudents(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<StudentInfo[]>;
19
60
  /**
20
61
  * A client for one of the account's students that reuses this client's
@@ -30,6 +71,26 @@ export declare class WilmaClient {
30
71
  * thread; older versions only have the HTML page, which is parsed instead.
31
72
  */
32
73
  get: (messageId: number) => Promise<Message>;
74
+ /**
75
+ * Who the parent can write to (Wilma's recipient list): the child, the
76
+ * other guardians, the child's own teachers, and the school's staff and
77
+ * teachers. Pass one to prepareMessage() in the write entry point.
78
+ */
79
+ recipients: () => Promise<MessageRecipient[]>;
80
+ };
81
+ student: {
82
+ /** The child's class and supervisors (class teacher, other supervisors), with their message links, from the front page. */
83
+ get: () => Promise<StudentDetails>;
84
+ };
85
+ invitations: {
86
+ /** Event invitations (Tapahtumakutsut), newest first, with whether this family has answered. */
87
+ list: () => Promise<InvitationListing[]>;
88
+ /**
89
+ * One invitation's time slots and this family's choice; null when the
90
+ * message isn't an invitation. Like opening it in Wilma, this marks the
91
+ * message read.
92
+ */
93
+ get: (messageId: number) => Promise<Invitation | null>;
33
94
  };
34
95
  news: {
35
96
  list: () => Promise<NewsItem[]>;
@@ -60,6 +121,15 @@ export declare class WilmaClient {
60
121
  start?: string;
61
122
  end?: string;
62
123
  }) => Promise<Exam[]>;
124
+ /**
125
+ * Past exams with their grades (Kokeet > Menneet), newest first. Wilma
126
+ * shows this school year by default; `from` (YYYY-MM-DD, to `to` or today)
127
+ * reaches further back, as far as the school keeps exams.
128
+ */
129
+ past: (opts?: {
130
+ from?: string;
131
+ to?: string;
132
+ }) => Promise<PastExam[]>;
63
133
  };
64
134
  attendance: {
65
135
  /**
@@ -72,6 +142,8 @@ export declare class WilmaClient {
72
142
  from?: string;
73
143
  to?: string;
74
144
  }) => Promise<LessonNote[]>;
145
+ /** Every lesson note of this school year (Wilma's own "whole year" view): one request. */
146
+ schoolYear: () => Promise<LessonNote[]>;
75
147
  /** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
76
148
  summary: (opts?: {
77
149
  from?: string;
package/dist/client.js CHANGED
@@ -2,8 +2,10 @@ import { WilmaSession, MfaRequiredError, APIError } from "./session.js";
2
2
  import { parseWilmaTimestamp } from "./parsers/dates.js";
3
3
  import { finnishDateString } from "./finnish-time.js";
4
4
  import { parseMessagesList, parseMessageDetailHtml, parseMessageDetailJson } from "./parsers/messages.js";
5
+ import { parseInvitationListings } from "./parsers/invitations.js";
6
+ import { parseRecipientsHtml, parseStudentDetailsHtml, recipientListSources } from "./parsers/recipients.js";
5
7
  import { parseNewsDetailHtml, parseNewsDetailJson, parseNewsList, parseNewsListHtml, } from "./parsers/news.js";
6
- import { addExamTimes, parseExamsHtml } from "./parsers/exams.js";
8
+ import { addExamTimes, parseExamsHtml, parsePastExamsHtml } from "./parsers/exams.js";
7
9
  import { parseAttendanceHtml, summarizeLessonNotes } from "./parsers/attendance.js";
8
10
  import { parseGradebookHtml } from "./parsers/gradebook.js";
9
11
  import { parsePrintoutsHtml } from "./parsers/printouts.js";
@@ -11,16 +13,21 @@ import { parseOverview } from "./parsers/overview.js";
11
13
  import { parseScheduleHtml, parseTimetableJson } from "./parsers/schedule.js";
12
14
  import { parseStudentsFromAccountsRoles, parseStudentsFromHome } from "./parsers/students.js";
13
15
  import { NetworkError } from "./network-error.js";
14
- import { DOWNLOAD_TIMEOUT_MS, fetchExternalFile, isHtmlResponse } from "./external-fetch.js";
16
+ import { DOWNLOAD_TIMEOUT_MS, getExternalFetcher, isHtmlResponse } from "./downloads.js";
17
+ import { sessionOf } from "./internal.js";
15
18
  export class WilmaClient {
16
19
  session;
17
20
  constructor(session) {
18
21
  this.session = session;
22
+ sessionOf.set(this, session);
19
23
  }
20
- static async login(profile, onMfaRequired) {
24
+ static async login(profile, onMfaRequired, options = {}) {
21
25
  const session = new WilmaSession(profile.baseUrl, {
22
26
  studentNumber: profile.studentNumber ?? null,
23
27
  debug: profile.debug ?? false,
28
+ transport: options.transport,
29
+ relogin: options.relogin,
30
+ shared: options.sharedSession,
24
31
  });
25
32
  session.setMfaCallback(onMfaRequired);
26
33
  try {
@@ -41,22 +48,57 @@ export class WilmaClient {
41
48
  * in. If Wilma has ended that session, the first request logs in again by
42
49
  * itself. Returns null when the saved state has no session.
43
50
  */
44
- static resume(profile, state, onMfaRequired) {
51
+ static resume(profile, state, onMfaRequired, options = {}) {
45
52
  const session = new WilmaSession(profile.baseUrl, {
46
53
  studentNumber: profile.studentNumber ?? null,
47
54
  debug: profile.debug ?? false,
55
+ transport: options.transport,
56
+ relogin: options.relogin,
57
+ shared: options.sharedSession,
48
58
  });
49
59
  session.setMfaCallback(onMfaRequired);
50
60
  return session.resumeState(state, profile.username, profile.password) ? new WilmaClient(session) : null;
51
61
  }
62
+ /**
63
+ * A client on a saved session without any credentials: it never logs in.
64
+ * When Wilma ends the session, requests fail with SessionEndedError. For
65
+ * apps that don't keep the parent's password. Returns null when the saved
66
+ * state has no session.
67
+ */
68
+ static resumeSession(baseUrl, state, options = {}) {
69
+ const session = new WilmaSession(baseUrl, { debug: options.debug, transport: options.transport, relogin: false, shared: options.sharedSession });
70
+ return session.resumeState(state) ? new WilmaClient(session) : null;
71
+ }
72
+ /** The Wilma this client talks to, e.g. "https://example.inschool.fi". */
73
+ get origin() {
74
+ return this.session.origin;
75
+ }
52
76
  /** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
53
77
  exportSession() {
54
78
  return this.session.exportState();
55
79
  }
80
+ /**
81
+ * End this login's session in Wilma (its "Log out"), e.g. so the parent can
82
+ * use Wilma in the browser. Never logs in. Afterwards this client and its
83
+ * forStudent() clients don't log in again by themselves. Returns true when
84
+ * a live session was ended, false when there was none; throws (keeping the
85
+ * session) when Wilma can't be reached.
86
+ */
87
+ logout() {
88
+ return this.session.logout();
89
+ }
90
+ /** Share the session with the other WilmAI programs on this computer from now on (see ClientOptions.sharedSession). */
91
+ useSharedSession(shared) {
92
+ this.session.setShared(shared);
93
+ }
56
94
  /** Run a callback after every successful login, e.g. to save the new session. */
57
95
  onLogin(callback) {
58
96
  this.session.onLogin(callback);
59
97
  }
98
+ /** Be told when this client logs in, continues another program's session, or finds its session ended (see SessionEvent). */
99
+ onSessionEvent(callback) {
100
+ this.session.onSessionEvent(callback);
101
+ }
60
102
  static async listStudents(profile, onMfaRequired) {
61
103
  const client = await WilmaClient.login({ ...profile, studentNumber: null }, onMfaRequired);
62
104
  return client.students();
@@ -146,6 +188,48 @@ export class WilmaClient {
146
188
  }
147
189
  return parseMessageDetailHtml(text, messageId);
148
190
  },
191
+ /**
192
+ * Who the parent can write to (Wilma's recipient list): the child, the
193
+ * other guardians, the child's own teachers, and the school's staff and
194
+ * teachers. Pass one to prepareMessage() in the write entry point.
195
+ */
196
+ recipients: async () => {
197
+ const resp = await this.session.get("/messages/recipients");
198
+ const html = await resp.text();
199
+ const recipients = parseRecipientsHtml(html, resp.url);
200
+ // Lists Wilma loads when opened (the child's own teachers, one by one).
201
+ for (const [group, path] of recipientListSources(html, resp.url)) {
202
+ const list = await this.session.get(path);
203
+ for (const recipient of parseRecipientsHtml(await list.text(), list.url, { group })) {
204
+ if (!recipients.some((r) => r.composePath === recipient.composePath && r.group === recipient.group))
205
+ recipients.push(recipient);
206
+ }
207
+ }
208
+ return recipients;
209
+ },
210
+ };
211
+ student = {
212
+ /** The child's class and supervisors (class teacher, other supervisors), with their message links, from the front page. */
213
+ get: async () => {
214
+ const resp = await this.session.get("/");
215
+ return parseStudentDetailsHtml(await resp.text(), resp.url);
216
+ },
217
+ };
218
+ invitations = {
219
+ /** Event invitations (Tapahtumakutsut), newest first, with whether this family has answered. */
220
+ list: async () => {
221
+ const resp = await this.session.get("/messages/list/appointments");
222
+ const data = safeJson(await resp.text());
223
+ return parseInvitationListings(data, parseMessagesList(data, "appointments"));
224
+ },
225
+ /**
226
+ * One invitation's time slots and this family's choice; null when the
227
+ * message isn't an invitation. Like opening it in Wilma, this marks the
228
+ * message read.
229
+ */
230
+ get: async (messageId) => {
231
+ return (await this.messages.get(messageId)).invitation ?? null;
232
+ },
149
233
  };
150
234
  news = {
151
235
  list: async () => {
@@ -187,7 +271,7 @@ export class WilmaClient {
187
271
  // Wilma handed the file off to another site: fetch it there, without Wilma credentials.
188
272
  const location = response.headers.get("location");
189
273
  await response.body?.cancel();
190
- const external = location ? await fetchExternalFile(new URL(location, url).href) : null;
274
+ const external = location ? await getExternalFetcher()(new URL(location, url).href) : null;
191
275
  return external
192
276
  ? { resource, response: external, status: "fetched" }
193
277
  : { resource, response: null, status: "not_a_file" };
@@ -196,9 +280,9 @@ export class WilmaClient {
196
280
  await response.body?.cancel();
197
281
  return { resource, response: null, status: "not_a_file" };
198
282
  }
199
- return { resource, response: response, status: "fetched" };
283
+ return { resource, response, status: "fetched" };
200
284
  }
201
- const response = await fetchExternalFile(resource.url);
285
+ const response = await getExternalFetcher()(resource.url);
202
286
  if (!response) {
203
287
  return { resource, response: null, status: "not_a_file" };
204
288
  }
@@ -241,6 +325,25 @@ export class WilmaClient {
241
325
  const text = await resp.text();
242
326
  return parseExamsHtml(text);
243
327
  },
328
+ /**
329
+ * Past exams with their grades (Kokeet > Menneet), newest first. Wilma
330
+ * shows this school year by default; `from` (YYYY-MM-DD, to `to` or today)
331
+ * reaches further back, as far as the school keeps exams.
332
+ */
333
+ past: async (opts) => {
334
+ let path = "/exams/calendar/past";
335
+ if (opts?.from || opts?.to) {
336
+ const to = opts.to ?? finnishDateString();
337
+ const [y, m] = to.split("-").map(Number);
338
+ // Without a start, the school year `to` falls in (from 1 August).
339
+ const from = opts.from ?? `${m >= 8 ? y : y - 1}-08-01`;
340
+ const fi = (iso) => iso.split("-").reverse().map(Number).join(".");
341
+ // Wilma's own form: range -3 is "between these dates".
342
+ path += `?${new URLSearchParams({ range: "-3", first: fi(from), last: fi(to) })}`;
343
+ }
344
+ const resp = await this.session.get(path);
345
+ return parsePastExamsHtml(await resp.text());
346
+ },
244
347
  };
245
348
  attendance = {
246
349
  /**
@@ -254,10 +357,12 @@ export class WilmaClient {
254
357
  const notes = parseAttendanceHtml(await this.attendancePage(`range=-3&first=${isoDateToFinnish(from)}&last=${isoDateToFinnish(to)}`));
255
358
  return notes.filter((note) => note.date >= from && note.date <= to);
256
359
  },
360
+ /** Every lesson note of this school year (Wilma's own "whole year" view): one request. */
361
+ schoolYear: async () => parseAttendanceHtml(await this.attendancePage("range=-4")),
257
362
  /** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
258
363
  summary: async (opts) => {
259
364
  if (!opts?.from) {
260
- const notes = parseAttendanceHtml(await this.attendancePage("range=-4"));
365
+ const notes = await this.attendance.schoolYear();
261
366
  return summarizeLessonNotes(opts?.to ? notes.filter((note) => note.date <= opts.to) : notes, null, opts?.to ?? null);
262
367
  }
263
368
  const to = opts.to ?? finnishDateString();
@@ -320,7 +425,7 @@ export class WilmaClient {
320
425
  if (!printout)
321
426
  throw new Error(`Printout "${id}" not found`);
322
427
  const response = await this.session.get(printout.path, undefined, { timeoutMs: DOWNLOAD_TIMEOUT_MS });
323
- return { printout, response: response };
428
+ return { printout, response };
324
429
  },
325
430
  };
326
431
  overview = {
package/dist/core.d.ts ADDED
@@ -0,0 +1,16 @@
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
+ 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";
6
+ export { setDefaultTransport } from "./transport.js";
7
+ export type { WilmaTransport, WilmaRequest, WilmaReply } from "./transport.js";
8
+ export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
9
+ export type { ExternalFetcher } from "./downloads.js";
10
+ export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError } from "./network-error.js";
11
+ export { parseWilmaTimestamp } from "./parsers/dates.js";
12
+ export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
13
+ export { parseStudentsFromHome } from "./parsers/students.js";
14
+ export { htmlToText } from "./parsers/html-text.js";
15
+ export { addExamTimes, parsePastExamsHtml } from "./parsers/exams.js";
16
+ export { awaitingAnswer, slotState } from "./parsers/invitations.js";
package/dist/core.js ADDED
@@ -0,0 +1,11 @@
1
+ export { WilmaClient } from "./client.js";
2
+ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError, SessionEndedError } from "./session.js";
3
+ export { setDefaultTransport } from "./transport.js";
4
+ export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse, setExternalFetcher } from "./downloads.js";
5
+ export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError } from "./network-error.js";
6
+ export { parseWilmaTimestamp } from "./parsers/dates.js";
7
+ export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
8
+ export { parseStudentsFromHome } from "./parsers/students.js";
9
+ export { htmlToText } from "./parsers/html-text.js";
10
+ export { addExamTimes, parsePastExamsHtml } from "./parsers/exams.js";
11
+ export { awaitingAnswer, slotState } from "./parsers/invitations.js";
@@ -0,0 +1,11 @@
1
+ /** How long a download may stall (before answering, or mid-file) before it fails. */
2
+ export declare const DOWNLOAD_TIMEOUT_MS = 60000;
3
+ export declare function isHtmlResponse(response: {
4
+ headers: {
5
+ get(name: string): string | null;
6
+ };
7
+ }): boolean;
8
+ /** Fetch a file from another site without Wilma's cookies; null when the address serves no file. */
9
+ export type ExternalFetcher = (url: string) => Promise<Response | null>;
10
+ export declare function setExternalFetcher(fetcher: ExternalFetcher | null): void;
11
+ export declare function getExternalFetcher(): ExternalFetcher;
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Download helpers that work on any runtime. Fetching files from other sites
3
+ * (bulletin links) needs network guards only Node can give (see
4
+ * external-fetch.ts), so it's a hook the Node entry point fills in.
5
+ */
6
+ /** How long a download may stall (before answering, or mid-file) before it fails. */
7
+ export const DOWNLOAD_TIMEOUT_MS = 60_000;
8
+ export function isHtmlResponse(response) {
9
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
10
+ return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
11
+ }
12
+ let externalFetcher = null;
13
+ export function setExternalFetcher(fetcher) {
14
+ externalFetcher = fetcher;
15
+ }
16
+ export function getExternalFetcher() {
17
+ if (!externalFetcher) {
18
+ throw new Error("Downloading files from other sites needs the Node client (@wilm-ai/wilma-client).");
19
+ }
20
+ return externalFetcher;
21
+ }
@@ -1,14 +1,8 @@
1
- import { type Response } from "undici";
2
- /** How long a download may stall (before answering, or mid-file) before it fails. */
3
- export declare const DOWNLOAD_TIMEOUT_MS = 60000;
1
+ import { DOWNLOAD_TIMEOUT_MS, isHtmlResponse } from "./downloads.js";
2
+ export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse };
4
3
  export declare function isBlockedAddress(address: string): boolean;
5
4
  /** Refuse URLs that point at private or special addresses, or at unusual ports. */
6
5
  export declare function assertPublicUrl(url: URL): void;
7
- export declare function isHtmlResponse(response: {
8
- headers: {
9
- get(name: string): string | null;
10
- };
11
- }): boolean;
12
6
  /**
13
7
  * Fetch an external URL the way a signed-out browser would: an isolated
14
8
  * in-memory cookie jar, redirects followed across hosts, and no Wilma
@@ -18,8 +18,8 @@ import { Agent, fetch, Headers } from "undici";
18
18
  import { APIError } from "./session.js";
19
19
  import { NetworkError } from "./network-error.js";
20
20
  import { IdleTimer, asNetworkError, watchBody } from "./timeouts.js";
21
- /** How long a download may stall (before answering, or mid-file) before it fails. */
22
- export const DOWNLOAD_TIMEOUT_MS = 60_000;
21
+ import { DOWNLOAD_TIMEOUT_MS, isHtmlResponse } from "./downloads.js";
22
+ export { DOWNLOAD_TIMEOUT_MS, isHtmlResponse };
23
23
  const MAX_REDIRECTS = 10;
24
24
  const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
25
25
  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
@@ -138,10 +138,6 @@ function guardedLookup(hostname, options, callback) {
138
138
  });
139
139
  }
140
140
  const guardedAgent = new Agent({ connect: { lookup: guardedLookup } });
141
- export function isHtmlResponse(response) {
142
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
143
- return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
144
- }
145
141
  // Some document hosts serve an HTML viewer page for a sharing URL but return
146
142
  // the file itself when a conventional download parameter is present. These are
147
143
  // generic retry variants, not provider detection: the original URL is always
package/dist/index.d.ts CHANGED
@@ -1,11 +1,3 @@
1
- export type { Exam, ExamGrade, GradebookEntry, HomeworkItem, LessonNote, LessonNoteSummary, Message, MessageReply, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, Printout, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
2
- export { WilmaClient } from "./client.js";
3
- export type { MfaCallback } from "./client.js";
4
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from "./session.js";
5
- export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
6
- export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
7
- export { parseWilmaTimestamp } from "./parsers/dates.js";
8
- export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
9
- export { parseStudentsFromHome } from "./parsers/students.js";
10
- export { htmlToText } from "./parsers/html-text.js";
11
- export { addExamTimes } from "./parsers/exams.js";
1
+ export * from "./core.js";
2
+ export { nodeTransport } from "./transport-node.js";
3
+ export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl } from "./tenants.js";
package/dist/index.js CHANGED
@@ -1,9 +1,14 @@
1
- export { WilmaClient } from "./client.js";
2
- export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from "./session.js";
3
- export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
4
- export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
5
- export { parseWilmaTimestamp } from "./parsers/dates.js";
6
- export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
7
- export { parseStudentsFromHome } from "./parsers/students.js";
8
- export { htmlToText } from "./parsers/html-text.js";
9
- export { addExamTimes } from "./parsers/exams.js";
1
+ /*
2
+ * @wilm-ai/wilma-client for Node: the core client (./core.js) with Node's
3
+ * defaults — requests through undici, files from other sites through the
4
+ * guarded fetcher, and the bundled list of Wilmas read from disk.
5
+ */
6
+ import { setDefaultTransport } from "./transport.js";
7
+ import { setExternalFetcher } from "./downloads.js";
8
+ import { nodeTransport } from "./transport-node.js";
9
+ import { fetchExternalFile } from "./external-fetch.js";
10
+ setDefaultTransport(nodeTransport);
11
+ setExternalFetcher(fetchExternalFile);
12
+ export * from "./core.js";
13
+ export { nodeTransport } from "./transport-node.js";
14
+ export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl } from "./tenants.js";
@@ -0,0 +1,3 @@
1
+ import type { WilmaSession } from "./session.js";
2
+ /** Each client's session, so write functions (a separate entry point) can use it. */
3
+ export declare const sessionOf: WeakMap<object, WilmaSession>;
@@ -0,0 +1,2 @@
1
+ /** Each client's session, so write functions (a separate entry point) can use it. */
2
+ export const sessionOf = new WeakMap();
@@ -1,5 +1,14 @@
1
- import type { Exam, UpcomingExam } from "../types.js";
1
+ import type { Exam, PastExam, UpcomingExam } from "../types.js";
2
2
  export declare function parseExamsHtml(html: string): Exam[];
3
+ /**
4
+ * Wilma's past exams page (Kokeet > Menneet): a table with a row per exam,
5
+ * columns seen, date, teachers, exam, notes, grade and verbal assessment. The
6
+ * exam cell reads "name : group course-code : course title", e.g.
7
+ * "Verbikoe 4 : RUB1_ Ruotsin kieli, B1.t RUB1_91 : Ruotsin kieli, B1".
8
+ * Columns are found by their headings (Finnish, Swedish or English), or by
9
+ * Wilma's usual order. Newest first.
10
+ */
11
+ export declare function parsePastExamsHtml(html: string): PastExam[];
3
12
  /**
4
13
  * Add start times from the exam calendar to upcoming exams (the front page's
5
14
  * exam list has dates only). An exam is matched by date and its course code