@wilm-ai/wilma-client 1.6.1 → 1.7.1
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 +48 -1
- package/dist/client.d.ts +76 -4
- package/dist/client.js +114 -9
- package/dist/core.d.ts +16 -0
- package/dist/core.js +11 -0
- package/dist/downloads.d.ts +11 -0
- package/dist/downloads.js +21 -0
- package/dist/external-fetch.d.ts +2 -8
- package/dist/external-fetch.js +2 -6
- package/dist/index.d.ts +3 -11
- package/dist/index.js +14 -9
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +2 -0
- package/dist/parsers/exams.d.ts +10 -1
- package/dist/parsers/exams.js +91 -0
- package/dist/parsers/forms.d.ts +40 -0
- package/dist/parsers/forms.js +93 -0
- package/dist/parsers/html-text.d.ts +4 -3
- package/dist/parsers/html-text.js +41 -10
- package/dist/parsers/invitations.d.ts +13 -0
- package/dist/parsers/invitations.js +121 -0
- package/dist/parsers/messages.js +4 -0
- package/dist/parsers/news.js +8 -4
- package/dist/parsers/overview.js +1 -0
- package/dist/parsers/recipients.d.ts +16 -0
- package/dist/parsers/recipients.js +170 -0
- package/dist/parsers/students.js +8 -2
- package/dist/session.d.ts +102 -10
- package/dist/session.js +241 -34
- package/dist/timeouts.d.ts +17 -2
- package/dist/timeouts.js +23 -14
- package/dist/transport-node.d.ts +2 -0
- package/dist/transport-node.js +19 -0
- package/dist/transport.d.ts +21 -0
- package/dist/transport.js +19 -0
- package/dist/types.d.ts +111 -0
- package/dist/write.d.ts +143 -0
- package/dist/write.js +427 -0
- package/package.json +11 -3
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.
|
|
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
|
|
2
|
-
import type {
|
|
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,
|
|
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
|
|
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
|
|
283
|
+
return { resource, response, status: "fetched" };
|
|
200
284
|
}
|
|
201
|
-
const response = await
|
|
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 =
|
|
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
|
|
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
|
+
}
|
package/dist/external-fetch.d.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
package/dist/external-fetch.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
export
|
|
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
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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";
|
package/dist/internal.js
ADDED
package/dist/parsers/exams.d.ts
CHANGED
|
@@ -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
|