@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/dist/write.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @wilm-ai/wilma-client/write: changing things in Wilma (answering messages,
|
|
3
|
+
* writing to teachers, signing up for event invitations, reporting an
|
|
4
|
+
* absence). Experimental. A separate entry point, so read-only tools such as
|
|
5
|
+
* the wilma CLI and its MCP server don't carry it.
|
|
6
|
+
*
|
|
7
|
+
* Every write takes two steps, like Wilma's own pages. Prepare reads Wilma's
|
|
8
|
+
* form and changes nothing. Send posts that form once, as Wilma's page would:
|
|
9
|
+
* the fields Wilma filled in, plus what the parent wrote (as HTML where the
|
|
10
|
+
* page has an editor, as Wilma's message forms do). A send is never repeated, and never retried after a login: when
|
|
11
|
+
* Wilma has ended the session it fails with SessionEndedError and nothing was
|
|
12
|
+
* sent. A form prepared under one session is never posted under another
|
|
13
|
+
* (another program's session continued, or a new login): that also fails
|
|
14
|
+
* with SessionEndedError, and preparing again gives a form for the session
|
|
15
|
+
* now in use. Afterwards Wilma's answer is checked and, where Wilma shows it,
|
|
16
|
+
* the result is read back:
|
|
17
|
+
* - WriteRefusedError: Wilma said no; nothing changed.
|
|
18
|
+
* - WriteUnconfirmedError: the form reached Wilma but the result didn't
|
|
19
|
+
* show; it may have gone through, so check Wilma before sending again.
|
|
20
|
+
*
|
|
21
|
+
* Writes are per child: pass client.forStudent(studentNumber).
|
|
22
|
+
*/
|
|
23
|
+
import * as cheerio from "cheerio";
|
|
24
|
+
import { SessionEndedError } from "./session.js";
|
|
25
|
+
import { sessionOf } from "./internal.js";
|
|
26
|
+
import { pageErrors, pageFormkey, pageNotices, readForm, setField, wilmaPath } from "./parsers/forms.js";
|
|
27
|
+
import { slotState } from "./parsers/invitations.js";
|
|
28
|
+
import { composePath } from "./parsers/recipients.js";
|
|
29
|
+
export { FormOutsideWilmaError } from "./parsers/forms.js";
|
|
30
|
+
/** Wilma doesn't offer this here right now (no answer form, absence reports closed…). Nothing was sent. */
|
|
31
|
+
export class NotOfferedError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "NotOfferedError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Wilma turned the form down and said so. Nothing changed. */
|
|
38
|
+
export class WriteRefusedError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "WriteRefusedError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The form reached Wilma, but the result didn't show. It may have gone through: check Wilma before sending again. */
|
|
45
|
+
export class WriteUnconfirmedError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "WriteUnconfirmedError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Wilma's day numbers count days from 30 December 1899 (46302 = 7 October 2026). */
|
|
52
|
+
export function wilmaDayToIso(value) {
|
|
53
|
+
const n = Number(value);
|
|
54
|
+
if (!Number.isInteger(n) || n < 30000 || n > 80000)
|
|
55
|
+
return null;
|
|
56
|
+
return new Date(Date.UTC(1899, 11, 30) + n * 86_400_000).toISOString().slice(0, 10);
|
|
57
|
+
}
|
|
58
|
+
export async function prepareAbsenceReport(client) {
|
|
59
|
+
const session = sessionFor(client);
|
|
60
|
+
const { $, url } = await page(session, "/attendance/report");
|
|
61
|
+
const el = $("form")
|
|
62
|
+
.filter((_, f) => /savereport/i.test($(f).attr("action") ?? ""))
|
|
63
|
+
.first();
|
|
64
|
+
if (!el.length)
|
|
65
|
+
throw new NotOfferedError("Wilma doesn't offer an absence report for this child right now.");
|
|
66
|
+
const form = readForm($, el, url);
|
|
67
|
+
const select = form.selects[0];
|
|
68
|
+
const reasons = (select?.options ?? [])
|
|
69
|
+
.filter((o) => o.value && o.value !== "0" && !/^-*$/.test(o.label))
|
|
70
|
+
.map(({ value, label }) => ({ value, label }));
|
|
71
|
+
if (!select || !reasons.length)
|
|
72
|
+
throw new NotOfferedError("Wilma's absence form has no reasons to choose from.");
|
|
73
|
+
const reportDate = form.fields.find(([name]) => /^reportdate$/i.test(name))?.[1];
|
|
74
|
+
const heading = $("h1, h2, h3, h4")
|
|
75
|
+
.map((_, h) => clean($(h).text()))
|
|
76
|
+
.get()
|
|
77
|
+
.find((t) => /poissaolon ajankohta|frånvarons tidpunkt|time of absence/i.test(t));
|
|
78
|
+
const guidance = $(".panel-body p, .alert, .info, .notification")
|
|
79
|
+
.map((_, p) => clean($(p).text()))
|
|
80
|
+
.get()
|
|
81
|
+
.find((t) => /ilmoit|anmäl|report/i.test(t)) ?? "";
|
|
82
|
+
const textField = el.find("input[type=text][name], textarea[name]").first().attr("name") ?? null;
|
|
83
|
+
return {
|
|
84
|
+
kind: "absence-report",
|
|
85
|
+
sessionMark: session.sessionMark,
|
|
86
|
+
date: reportDate ? wilmaDayToIso(reportDate) : null,
|
|
87
|
+
dateLabel: heading ? heading.replace(/^[^:]*:\s*/, "") : "",
|
|
88
|
+
reasons,
|
|
89
|
+
acceptsText: textField !== null,
|
|
90
|
+
guidance,
|
|
91
|
+
form,
|
|
92
|
+
reasonField: select.name,
|
|
93
|
+
textField,
|
|
94
|
+
noticesBefore: pageNotices($),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Report the absence (once). Returns Wilma's confirmation. */
|
|
98
|
+
export async function sendAbsenceReport(client, prepared, input) {
|
|
99
|
+
const session = sessionFor(client);
|
|
100
|
+
if (prepared.kind !== "absence-report")
|
|
101
|
+
throw new TypeError("Expected a form from prepareAbsenceReport()");
|
|
102
|
+
if (!prepared.reasons.some((r) => r.value === input.reason))
|
|
103
|
+
throw new TypeError("Pick one of the form's reasons");
|
|
104
|
+
let fields = setField(prepared.form.fields, prepared.reasonField, input.reason);
|
|
105
|
+
if (prepared.textField)
|
|
106
|
+
fields = setField(fields, prepared.textField, input.text ?? "");
|
|
107
|
+
fields = withButton(fields, prepared.form, "savebtn");
|
|
108
|
+
sameSession(session, prepared);
|
|
109
|
+
const answer = await read(await session.submit(prepared.form.action, fields), "The report");
|
|
110
|
+
const refused = pageErrors(answer.$);
|
|
111
|
+
if (refused)
|
|
112
|
+
throw new WriteRefusedError(`Wilma didn't take the report: ${refused}`);
|
|
113
|
+
const notice = pageNotices(answer.$).find((n) => !prepared.noticesBefore.includes(n));
|
|
114
|
+
if (notice)
|
|
115
|
+
return { notice };
|
|
116
|
+
throw new WriteUnconfirmedError("Wilma didn't confirm the report. Check the child's lesson notes in Wilma before reporting again.");
|
|
117
|
+
}
|
|
118
|
+
export async function prepareReply(client, messageId) {
|
|
119
|
+
const session = sessionFor(client);
|
|
120
|
+
const { $, url } = await page(session, `/messages/${messageId}`);
|
|
121
|
+
const quick = $("form#quickreply-form").first();
|
|
122
|
+
if (quick.length && quick.attr("action")) {
|
|
123
|
+
const form = readForm($, quick, url);
|
|
124
|
+
sendButton(form, "quickbtn");
|
|
125
|
+
return { kind: "reply", sessionMark: session.sessionMark, messageId, mode: "discussion", subject: null, recipients: [], form, bodyField: form.textareas[0] ?? "bodytext" };
|
|
126
|
+
}
|
|
127
|
+
let href;
|
|
128
|
+
$("a[href]").each((_, a) => {
|
|
129
|
+
const candidate = $(a).attr("href") ?? "";
|
|
130
|
+
// "Vastaa viestin lähettäjälle" links to compose?replyid=<id>; its neighbour "Välitä" adds &forward.
|
|
131
|
+
if (href || candidate.startsWith("#") || /forward/i.test(candidate))
|
|
132
|
+
return;
|
|
133
|
+
if (/^\s*(vastaa|svara|reply)\b/i.test($(a).text()) || /compose.*(answer|reply|replyid)/i.test(candidate))
|
|
134
|
+
href = candidate;
|
|
135
|
+
});
|
|
136
|
+
if (!href)
|
|
137
|
+
throw new NotOfferedError("Wilma doesn't offer an answer to this message.");
|
|
138
|
+
const compose = await composeForm(session, wilmaPath(href, url));
|
|
139
|
+
return {
|
|
140
|
+
kind: "reply",
|
|
141
|
+
sessionMark: session.sessionMark,
|
|
142
|
+
messageId,
|
|
143
|
+
mode: "private",
|
|
144
|
+
subject: compose.subject,
|
|
145
|
+
recipients: compose.recipients,
|
|
146
|
+
form: compose.form,
|
|
147
|
+
bodyField: compose.bodyField,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Send the answer (once). */
|
|
151
|
+
export async function sendReply(client, prepared, text) {
|
|
152
|
+
const session = sessionFor(client);
|
|
153
|
+
if (prepared.kind !== "reply")
|
|
154
|
+
throw new TypeError("Expected a form from prepareReply()");
|
|
155
|
+
if (!text.trim())
|
|
156
|
+
throw new TypeError("The answer is empty");
|
|
157
|
+
const before = await sentState(client, prepared.messageId);
|
|
158
|
+
const body = editorBody(prepared.form, text);
|
|
159
|
+
const fields = withButton(setField(prepared.form.fields, prepared.bodyField, body), prepared.form, prepared.mode === "discussion" ? "quickbtn" : "addsavebtn");
|
|
160
|
+
sameSession(session, prepared);
|
|
161
|
+
const answer = await read(await session.submit(prepared.form.action, fields), "The answer");
|
|
162
|
+
const refused = pageErrors(answer.$);
|
|
163
|
+
if (refused)
|
|
164
|
+
throw new WriteRefusedError(`Wilma didn't send the answer: ${refused}`);
|
|
165
|
+
if (await wasSent(client, before, { messageId: prepared.messageId, subject: prepared.subject, text }))
|
|
166
|
+
return;
|
|
167
|
+
throw new WriteUnconfirmedError("Wilma didn't show the answer as sent. Check the thread in Wilma before answering again.");
|
|
168
|
+
}
|
|
169
|
+
export async function prepareMessage(client, to) {
|
|
170
|
+
const session = sessionFor(client);
|
|
171
|
+
let path;
|
|
172
|
+
if ("composePath" in to) {
|
|
173
|
+
// A link Wilma gave, kept to its own compose page and this child.
|
|
174
|
+
const own = composePath(to.composePath, `${client.origin}/`);
|
|
175
|
+
if (!own || (own.startsWith("/!") && !own.startsWith(session.getPrefixedPath("/"))))
|
|
176
|
+
throw new TypeError("Not a compose link for this child");
|
|
177
|
+
path = own;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const id = String(to.teacherId);
|
|
181
|
+
if (!/^\d+$/.test(id))
|
|
182
|
+
throw new TypeError("A teacher id is a number");
|
|
183
|
+
path = `/messages/compose?r_teacher=${id}`;
|
|
184
|
+
}
|
|
185
|
+
const compose = await composeForm(session, path);
|
|
186
|
+
if (!compose.subjectField)
|
|
187
|
+
throw new NotOfferedError("Wilma's message form has no subject field.");
|
|
188
|
+
return { kind: "message", sessionMark: session.sessionMark, recipients: compose.recipients, form: compose.form, subjectField: compose.subjectField, bodyField: compose.bodyField };
|
|
189
|
+
}
|
|
190
|
+
/** Send the message (once). */
|
|
191
|
+
export async function sendMessage(client, prepared, input) {
|
|
192
|
+
const session = sessionFor(client);
|
|
193
|
+
if (prepared.kind !== "message")
|
|
194
|
+
throw new TypeError("Expected a form from prepareMessage()");
|
|
195
|
+
if (!input.subject.trim())
|
|
196
|
+
throw new TypeError("The subject is empty");
|
|
197
|
+
if (!input.text.trim())
|
|
198
|
+
throw new TypeError("The message is empty");
|
|
199
|
+
const before = await sentState(client, null);
|
|
200
|
+
let fields = setField(prepared.form.fields, prepared.subjectField, input.subject);
|
|
201
|
+
fields = withButton(setField(fields, prepared.bodyField, editorBody(prepared.form, input.text)), prepared.form, "addsavebtn");
|
|
202
|
+
sameSession(session, prepared);
|
|
203
|
+
const answer = await read(await session.submit(prepared.form.action, fields), "The message");
|
|
204
|
+
const refused = pageErrors(answer.$);
|
|
205
|
+
if (refused)
|
|
206
|
+
throw new WriteRefusedError(`Wilma didn't send the message: ${refused}`);
|
|
207
|
+
if (await wasSent(client, before, { messageId: null, subject: input.subject, text: input.text }))
|
|
208
|
+
return;
|
|
209
|
+
throw new WriteUnconfirmedError("Wilma didn't show the message as sent. Check sent messages in Wilma before sending again.");
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Event invitations
|
|
213
|
+
/**
|
|
214
|
+
* Sign up for an invitation's time slot, or cancel the sign-up. This is what
|
|
215
|
+
* Wilma's own page sends (appointments.js): POST `/messages/apply/` with the
|
|
216
|
+
* page's formkey, apptId (the slot), action, appliedpersons and kortti (the
|
|
217
|
+
* message), answered with JSON {resultmessage: {message, problem}}. Checks
|
|
218
|
+
* the slot's state first, like Wilma's page, and reads the invitation back
|
|
219
|
+
* afterwards. Opening the invitation marks it read, as in Wilma.
|
|
220
|
+
*/
|
|
221
|
+
export async function answerInvitation(client, messageId, slotId, action, opts = {}) {
|
|
222
|
+
const session = sessionFor(client);
|
|
223
|
+
const invitation = (await client.messages.get(messageId)).invitation;
|
|
224
|
+
if (!invitation)
|
|
225
|
+
throw new NotOfferedError("This message isn't an event invitation.");
|
|
226
|
+
const slot = invitation.slots.find((s) => s.id === slotId);
|
|
227
|
+
if (!slot)
|
|
228
|
+
throw new NotOfferedError("The invitation has no such time slot.");
|
|
229
|
+
const state = slotState(slot, new Date(), invitation);
|
|
230
|
+
if (action === "apply" && state !== "open")
|
|
231
|
+
throw new NotOfferedError(`Signing up isn't open for this time (${state}).`);
|
|
232
|
+
if (action === "cancel" && state !== "chosen")
|
|
233
|
+
throw new NotOfferedError(`This sign-up can't be cancelled (${state}).`);
|
|
234
|
+
const { $ } = await page(session, `/messages/${messageId}`);
|
|
235
|
+
const mark = session.sessionMark;
|
|
236
|
+
const formkey = pageFormkey($);
|
|
237
|
+
if (!formkey)
|
|
238
|
+
throw new NotOfferedError("Wilma's invitation page has no form key.");
|
|
239
|
+
const fields = [
|
|
240
|
+
["formkey", formkey],
|
|
241
|
+
["apptId", slotId],
|
|
242
|
+
["action", action],
|
|
243
|
+
["appliedpersons", String(opts.persons ?? 1)],
|
|
244
|
+
["kortti", String(messageId)],
|
|
245
|
+
];
|
|
246
|
+
sameSession(session, { sessionMark: mark });
|
|
247
|
+
const resp = await session.submit("/messages/apply/", fields, {
|
|
248
|
+
headers: { "X-Requested-With": "XMLHttpRequest", Accept: "application/json, text/javascript, */*; q=0.01" },
|
|
249
|
+
});
|
|
250
|
+
const answer = await read(resp, "The sign-up");
|
|
251
|
+
let result;
|
|
252
|
+
try {
|
|
253
|
+
result = JSON.parse(answer.html);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
throw new WriteUnconfirmedError("Wilma's answer wasn't what its own page expects. Check the invitation in Wilma.");
|
|
257
|
+
}
|
|
258
|
+
const notice = typeof result.resultmessage?.message === "string" ? clean(result.resultmessage.message) : "";
|
|
259
|
+
if (result.resultmessage?.problem === "problem")
|
|
260
|
+
throw new WriteRefusedError(notice || "Wilma refused the change.");
|
|
261
|
+
const after = (await client.messages.get(messageId)).invitation;
|
|
262
|
+
const chosen = after?.slots.find((s) => s.id === slotId)?.selected;
|
|
263
|
+
if (!after || chosen !== (action === "apply")) {
|
|
264
|
+
throw new WriteUnconfirmedError("The invitation doesn't show the change yet. Check it in Wilma before trying again.");
|
|
265
|
+
}
|
|
266
|
+
return { notice, invitation: after };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Say the family won't attend ("En osallistu"), or take that back (`undo`).
|
|
270
|
+
* This is what Wilma's own page sends (appointments.js, dismissAppt): POST
|
|
271
|
+
* `/messages/apply` with the page's formkey, action "dismiss" (or
|
|
272
|
+
* "canceldismiss") and kortti (the message), no slot. Declining also cancels
|
|
273
|
+
* a sign-up that can still be cancelled, as Wilma's page warns. Reads the
|
|
274
|
+
* invitation back afterwards.
|
|
275
|
+
*/
|
|
276
|
+
export async function declineInvitation(client, messageId, opts = {}) {
|
|
277
|
+
const session = sessionFor(client);
|
|
278
|
+
const invitation = (await client.messages.get(messageId)).invitation;
|
|
279
|
+
if (!invitation)
|
|
280
|
+
throw new NotOfferedError("This message isn't an event invitation.");
|
|
281
|
+
if (!invitation.canDecline)
|
|
282
|
+
throw new NotOfferedError("Wilma doesn't offer \"won't attend\" for this invitation.");
|
|
283
|
+
if (opts.undo ? !invitation.declined : invitation.declined) {
|
|
284
|
+
throw new NotOfferedError(opts.undo ? "The family hasn't said it won't attend." : "The family has already said it won't attend.");
|
|
285
|
+
}
|
|
286
|
+
const { $ } = await page(session, `/messages/${messageId}`);
|
|
287
|
+
const mark = session.sessionMark;
|
|
288
|
+
const formkey = pageFormkey($);
|
|
289
|
+
if (!formkey)
|
|
290
|
+
throw new NotOfferedError("Wilma's invitation page has no form key.");
|
|
291
|
+
const fields = [
|
|
292
|
+
["formkey", formkey],
|
|
293
|
+
["action", opts.undo ? "canceldismiss" : "dismiss"],
|
|
294
|
+
["kortti", String(messageId)],
|
|
295
|
+
];
|
|
296
|
+
sameSession(session, { sessionMark: mark });
|
|
297
|
+
const resp = await session.submit("/messages/apply", fields, {
|
|
298
|
+
headers: { "X-Requested-With": "XMLHttpRequest", Accept: "application/json, text/javascript, */*; q=0.01" },
|
|
299
|
+
});
|
|
300
|
+
const answer = await read(resp, "The answer");
|
|
301
|
+
let result;
|
|
302
|
+
try {
|
|
303
|
+
result = JSON.parse(answer.html);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
throw new WriteUnconfirmedError("Wilma's answer wasn't what its own page expects. Check the invitation in Wilma.");
|
|
307
|
+
}
|
|
308
|
+
const notice = typeof result.resultmessage?.message === "string" ? clean(result.resultmessage.message) : "";
|
|
309
|
+
if (result.resultmessage?.problem === "problem")
|
|
310
|
+
throw new WriteRefusedError(notice || "Wilma refused the change.");
|
|
311
|
+
const after = (await client.messages.get(messageId)).invitation;
|
|
312
|
+
if (!after || after.declined !== !opts.undo) {
|
|
313
|
+
throw new WriteUnconfirmedError("The invitation doesn't show the change yet. Check it in Wilma before trying again.");
|
|
314
|
+
}
|
|
315
|
+
return { notice, invitation: after };
|
|
316
|
+
}
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
/** A form's key belongs to the session it was read in: refuse to post it under another one. */
|
|
319
|
+
function sameSession(session, prepared) {
|
|
320
|
+
if (prepared.sessionMark !== undefined && prepared.sessionMark !== session.sessionMark) {
|
|
321
|
+
throw new SessionEndedError("The Wilma session changed after the form was prepared; nothing was sent. Prepare it again.");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function sessionFor(client) {
|
|
325
|
+
const session = sessionOf.get(client);
|
|
326
|
+
if (!session)
|
|
327
|
+
throw new TypeError("Expected a WilmaClient");
|
|
328
|
+
if (session.getPrefixedPath("/") === "/")
|
|
329
|
+
throw new TypeError("Writes are per child: pass client.forStudent(studentNumber)");
|
|
330
|
+
return session;
|
|
331
|
+
}
|
|
332
|
+
const clean = (text) => text.replace(/\s+/g, " ").trim();
|
|
333
|
+
async function page(session, path) {
|
|
334
|
+
const resp = await session.get(path);
|
|
335
|
+
const html = await resp.text();
|
|
336
|
+
return { $: cheerio.load(html), url: resp.url };
|
|
337
|
+
}
|
|
338
|
+
/** Wilma's answer to a sent form. 5xx: it may have been saved before failing. */
|
|
339
|
+
async function read(resp, what) {
|
|
340
|
+
const html = await resp.text();
|
|
341
|
+
if (resp.status >= 500) {
|
|
342
|
+
throw new WriteUnconfirmedError(`Wilma answered HTTP ${resp.status}. ${what} may or may not have gone through: check Wilma before trying again.`);
|
|
343
|
+
}
|
|
344
|
+
if (resp.status >= 400)
|
|
345
|
+
throw new WriteRefusedError(`Wilma answered HTTP ${resp.status}; nothing was changed.`);
|
|
346
|
+
return { $: cheerio.load(html), html, url: resp.url };
|
|
347
|
+
}
|
|
348
|
+
/** Wilma's compose form (new message or private answer): the form with a text area, never logout. */
|
|
349
|
+
async function composeForm(session, path) {
|
|
350
|
+
const { $, url } = await page(session, path);
|
|
351
|
+
const el = $("form")
|
|
352
|
+
.filter((_, f) => !/logout/i.test($(f).attr("action") ?? "") && $(f).find("textarea[name]").length > 0)
|
|
353
|
+
.first();
|
|
354
|
+
if (!el.length)
|
|
355
|
+
throw new NotOfferedError("Wilma's message form wasn't where it usually is.");
|
|
356
|
+
const form = readForm($, el, url);
|
|
357
|
+
// A recipient Wilma filled in (r_teacher, r_groupteachers…): without one nothing would be delivered.
|
|
358
|
+
if (!form.fields.some(([name]) => /^[rs]_/.test(name))) {
|
|
359
|
+
throw new NotOfferedError("Wilma's message form has no recipient, so nothing would be delivered.");
|
|
360
|
+
}
|
|
361
|
+
sendButton(form, "addsavebtn");
|
|
362
|
+
const subjectField = form.fields.find(([name]) => /^subject$/i.test(name))?.[0] ?? null;
|
|
363
|
+
return {
|
|
364
|
+
form,
|
|
365
|
+
recipients: el
|
|
366
|
+
.find(".recipient-item")
|
|
367
|
+
.map((_, item) => clean($(item).children("span").first().text() || $(item).text()))
|
|
368
|
+
.get()
|
|
369
|
+
.filter(Boolean),
|
|
370
|
+
bodyField: form.textareas.find((name) => /body/i.test(name)) ?? form.textareas[0],
|
|
371
|
+
subjectField,
|
|
372
|
+
subject: subjectField ? (form.fields.find(([name]) => name === subjectField)?.[1] ?? null) : null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The form's send button, by Wilma's name for it. Never another one: a form
|
|
377
|
+
* also has buttons that save a draft, cancel, or open the recipient picker.
|
|
378
|
+
*/
|
|
379
|
+
function sendButton(form, name) {
|
|
380
|
+
const button = form.buttons.find((b) => b.name === name);
|
|
381
|
+
if (!button)
|
|
382
|
+
throw new NotOfferedError("Wilma's form has no send button where it usually is; nothing was sent.");
|
|
383
|
+
return button;
|
|
384
|
+
}
|
|
385
|
+
function withButton(fields, form, name) {
|
|
386
|
+
const button = sendButton(form, name);
|
|
387
|
+
return [...fields, [button.name, button.value]];
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* What goes in the body field. Wilma's message forms say `wysiwyg=ckeditor`:
|
|
391
|
+
* its editor sends HTML, so plain text becomes paragraphs (blank lines) and
|
|
392
|
+
* line breaks, with <, > and & escaped. Other forms get the text as is.
|
|
393
|
+
*/
|
|
394
|
+
export function editorBody(form, text) {
|
|
395
|
+
const mode = form.fields.find(([name]) => name.toLowerCase() === "wysiwyg")?.[1] ?? "";
|
|
396
|
+
if (!mode || /^(0|false|no|plain|text)$/i.test(mode))
|
|
397
|
+
return text;
|
|
398
|
+
const escape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
399
|
+
return text
|
|
400
|
+
.replace(/\r\n?/g, "\n")
|
|
401
|
+
.trim()
|
|
402
|
+
.split(/\n\s*\n/)
|
|
403
|
+
.map((paragraph) => `<p>${escape(paragraph.trim()).replace(/\n/g, "<br />\n")}</p>`)
|
|
404
|
+
.join("\n");
|
|
405
|
+
}
|
|
406
|
+
/** What's sent so far: outbox ids, and the thread's answers when there is a thread. */
|
|
407
|
+
async function sentState(client, messageId) {
|
|
408
|
+
const [outbox, thread] = await Promise.all([
|
|
409
|
+
outboxIds(client),
|
|
410
|
+
messageId === null ? null : client.messages.get(messageId),
|
|
411
|
+
]);
|
|
412
|
+
return { outbox, replies: thread?.replies?.length ?? 0 };
|
|
413
|
+
}
|
|
414
|
+
async function outboxIds(client) {
|
|
415
|
+
return new Set((await client.messages.list("outbox")).map((m) => m.wilmaId));
|
|
416
|
+
}
|
|
417
|
+
/** Did it arrive: a new answer with this text in the thread, or a new message with this subject in sent messages? */
|
|
418
|
+
async function wasSent(client, before, sent) {
|
|
419
|
+
const opening = clean(sent.text).slice(0, 40);
|
|
420
|
+
if (sent.messageId !== null) {
|
|
421
|
+
const replies = (await client.messages.get(sent.messageId)).replies ?? [];
|
|
422
|
+
if (replies.slice(before.replies).some((r) => clean(r.content).includes(opening)))
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
const outbox = await client.messages.list("outbox");
|
|
426
|
+
return outbox.some((m) => !before.outbox.has(m.wilmaId) && (!sent.subject || clean(m.subject) === clean(sent.subject)));
|
|
427
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wilm-ai/wilma-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Unofficial client for Wilma, the Finnish school information system: login (with two-step verification), schedule, homework, exams, messages and news.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./core": {
|
|
25
|
+
"types": "./dist/core.d.ts",
|
|
26
|
+
"default": "./dist/core.js"
|
|
27
|
+
},
|
|
28
|
+
"./write": {
|
|
29
|
+
"types": "./dist/write.d.ts",
|
|
30
|
+
"default": "./dist/write.js"
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"files": [
|
|
@@ -31,14 +39,14 @@
|
|
|
31
39
|
"scripts": {
|
|
32
40
|
"build": "tsc -p tsconfig.json",
|
|
33
41
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
34
|
-
"test": "pnpm run build && node test/login.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
|
|
42
|
+
"test": "pnpm run build && node test/login.mjs && node test/logout.mjs && node test/core.mjs && node test/write.mjs && node test/parser-attendance.mjs && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-attendance.mjs && node test/client-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
|
|
35
43
|
"test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
|
|
36
44
|
"fixtures:anonymize": "node scripts/anonymize-fixture.mjs"
|
|
37
45
|
},
|
|
38
46
|
"dependencies": {
|
|
39
47
|
"cheerio": "^1.0.0-rc.12",
|
|
40
48
|
"tough-cookie": "^4.1.4",
|
|
41
|
-
"undici": "^6.
|
|
49
|
+
"undici": "^6.29.1"
|
|
42
50
|
},
|
|
43
51
|
"devDependencies": {
|
|
44
52
|
"typescript": "^5.6.3",
|