@wilm-ai/wilma-client 1.7.1 → 1.9.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/dist/client.d.ts CHANGED
@@ -94,6 +94,11 @@ export declare class WilmaClient {
94
94
  };
95
95
  news: {
96
96
  list: () => Promise<NewsItem[]>;
97
+ /**
98
+ * The bulletins, or null when this Wilma doesn't show them to this account: it answers /news with 403 (Helsinki's
99
+ * does, for a guardian) or 404 while the session is alive. Its other pages read as usual.
100
+ */
101
+ listIfShown: () => Promise<NewsItem[] | null>;
97
102
  get: (newsId: number) => Promise<NewsItem>;
98
103
  fetchResource: (newsId: number, resourceId: string, options?: {
99
104
  item?: NewsItem;
package/dist/client.js CHANGED
@@ -241,6 +241,20 @@ export class WilmaClient {
241
241
  }
242
242
  return parseNewsListHtml(text);
243
243
  },
244
+ /**
245
+ * The bulletins, or null when this Wilma doesn't show them to this account: it answers /news with 403 (Helsinki's
246
+ * does, for a guardian) or 404 while the session is alive. Its other pages read as usual.
247
+ */
248
+ listIfShown: async () => {
249
+ try {
250
+ return await this.news.list();
251
+ }
252
+ catch (err) {
253
+ if (err instanceof APIError && (err.status === 403 || err.status === 404))
254
+ return null;
255
+ throw err;
256
+ }
257
+ },
244
258
  get: async (newsId) => {
245
259
  const resp = await this.session.get(`/news/${newsId}`);
246
260
  const contentType = resp.headers.get("content-type")?.toLowerCase() ?? "";
@@ -23,6 +23,12 @@ export interface WilmaForm {
23
23
  }[];
24
24
  }[];
25
25
  textareas: string[];
26
+ /** The form's checkboxes, ticked or not (`fields` has only the ticked ones, as a browser sends them). */
27
+ checkboxes?: {
28
+ name: string;
29
+ value: string;
30
+ checked: boolean;
31
+ }[];
26
32
  }
27
33
  export declare class FormOutsideWilmaError extends Error {
28
34
  constructor();
@@ -18,6 +18,7 @@ export function readForm($, form, pageUrl) {
18
18
  const buttons = [];
19
19
  const selects = [];
20
20
  const textareas = [];
21
+ const checkboxes = [];
21
22
  form.find("input, select, textarea, button").each((_, el) => {
22
23
  const control = $(el);
23
24
  const name = control.attr("name");
@@ -50,14 +51,17 @@ export function readForm($, form, pageUrl) {
50
51
  buttons.push({ name, value, label: clean(control.text()) || value });
51
52
  }
52
53
  else if (type === "checkbox" || type === "radio") {
53
- if (control.attr("checked") !== undefined)
54
+ const checked = control.attr("checked") !== undefined;
55
+ if (type === "checkbox")
56
+ checkboxes.push({ name, value: control.attr("value") ?? "on", checked });
57
+ if (checked)
54
58
  fields.push([name, control.attr("value") ?? "on"]);
55
59
  }
56
60
  else if (type !== "file" && type !== "reset" && type !== "button") {
57
61
  fields.push([name, value]);
58
62
  }
59
63
  });
60
- return { action: wilmaPath(form.attr("action") ?? "", pageUrl), fields, buttons, selects, textareas };
64
+ return { action: wilmaPath(form.attr("action") ?? "", pageUrl), fields, buttons, selects, textareas, checkboxes };
61
65
  }
62
66
  /** The form key on a page (Wilma's anti-forgery token), preferring one outside the logout form. */
63
67
  export function pageFormkey($) {
@@ -3,6 +3,12 @@ import type { MessageRecipient, StudentDetails } from "../types.js";
3
3
  export declare function composePath(href: string | undefined, pageUrl: string): string | null;
4
4
  /** What a compose link writes to, from its r_ parameters (a repeated or mixed one: several people). */
5
5
  export declare function recipientKind(path: string): MessageRecipient["kind"];
6
+ /**
7
+ * One compose link to several recipients, made as Wilma's own "to all teachers" links are: every r_… parameter of
8
+ * theirs, each once, and for more than one person showrecipients=1&collatedreplies=1 (the recipients see who else
9
+ * got it and each other's replies, which the message form can still untick). The links must be one child's.
10
+ */
11
+ export declare function composeToAll(paths: string[]): string;
6
12
  /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
7
13
  export declare function parseStudentDetailsHtml(html: string, pageUrl: string): StudentDetails;
8
14
  /**
@@ -51,6 +51,32 @@ export function recipientKind(path) {
51
51
  return "other";
52
52
  }
53
53
  }
54
+ /**
55
+ * One compose link to several recipients, made as Wilma's own "to all teachers" links are: every r_… parameter of
56
+ * theirs, each once, and for more than one person showrecipients=1&collatedreplies=1 (the recipients see who else
57
+ * got it and each other's replies, which the message form can still untick). The links must be one child's.
58
+ */
59
+ export function composeToAll(paths) {
60
+ if (!paths.length)
61
+ throw new TypeError("No recipients");
62
+ if (paths.length === 1)
63
+ return paths[0];
64
+ const urls = paths.map((p) => new URL(p, "https://wilma.invalid"));
65
+ if (urls.some((u) => u.pathname !== urls[0].pathname))
66
+ throw new TypeError("The recipients are another child's");
67
+ const params = [];
68
+ for (const u of urls) {
69
+ for (const [k, v] of u.searchParams)
70
+ if (k.startsWith("r_") && !params.some(([a, b]) => a === k && b === v))
71
+ params.push([k, v]);
72
+ }
73
+ const out = new URLSearchParams(params);
74
+ if (params.length > 1 || recipientKind(`?${out}`) === "group") {
75
+ out.set("showrecipients", "1");
76
+ out.set("collatedreplies", "1");
77
+ }
78
+ return `${urls[0].pathname}?${out}`;
79
+ }
54
80
  /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
55
81
  export function parseStudentDetailsHtml(html, pageUrl) {
56
82
  const $ = cheerio.load(html);
package/dist/write.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { WilmaClient } from "./client.js";
2
2
  import { type WilmaForm } from "./parsers/forms.js";
3
+ export { composeToAll } from "./parsers/recipients.js";
3
4
  import type { Invitation, MessageRecipient } from "./types.js";
4
5
  export type { WilmaForm } from "./parsers/forms.js";
5
6
  export { FormOutsideWilmaError } from "./parsers/forms.js";
@@ -98,14 +99,23 @@ export interface MessageForm {
98
99
  readonly form: WilmaForm;
99
100
  readonly subjectField: string;
100
101
  readonly bodyField: string;
102
+ /**
103
+ * Whether the recipients see each other's replies (Wilma's CollatedReplies box, which makes the thread an open
104
+ * discussion), as Wilma's form has it; null when the form has no such box.
105
+ */
106
+ readonly collatedReplies?: boolean | null;
101
107
  }
102
108
  export declare function prepareMessage(client: WilmaClient, to: {
103
109
  teacherId: string | number;
104
110
  } | Pick<MessageRecipient, "composePath">): Promise<MessageForm>;
105
- /** Send the message (once). */
111
+ /**
112
+ * Send the message (once). `collatedReplies` ticks or clears Wilma's "recipients see each other's replies" box
113
+ * when the form has one; left out, the box stays as Wilma's form has it.
114
+ */
106
115
  export declare function sendMessage(client: WilmaClient, prepared: MessageForm, input: {
107
116
  subject: string;
108
117
  text: string;
118
+ collatedReplies?: boolean;
109
119
  }): Promise<void>;
110
120
  /**
111
121
  * Sign up for an invitation's time slot, or cancel the sign-up. This is what
package/dist/write.js CHANGED
@@ -26,6 +26,7 @@ import { sessionOf } from "./internal.js";
26
26
  import { pageErrors, pageFormkey, pageNotices, readForm, setField, wilmaPath } from "./parsers/forms.js";
27
27
  import { slotState } from "./parsers/invitations.js";
28
28
  import { composePath } from "./parsers/recipients.js";
29
+ export { composeToAll } from "./parsers/recipients.js";
29
30
  export { FormOutsideWilmaError } from "./parsers/forms.js";
30
31
  /** Wilma doesn't offer this here right now (no answer form, absence reports closed…). Nothing was sent. */
31
32
  export class NotOfferedError extends Error {
@@ -166,6 +167,7 @@ export async function sendReply(client, prepared, text) {
166
167
  return;
167
168
  throw new WriteUnconfirmedError("Wilma didn't show the answer as sent. Check the thread in Wilma before answering again.");
168
169
  }
170
+ const COLLATED = "CollatedReplies";
169
171
  export async function prepareMessage(client, to) {
170
172
  const session = sessionFor(client);
171
173
  let path;
@@ -185,9 +187,21 @@ export async function prepareMessage(client, to) {
185
187
  const compose = await composeForm(session, path);
186
188
  if (!compose.subjectField)
187
189
  throw new NotOfferedError("Wilma's message form has no subject field.");
188
- return { kind: "message", sessionMark: session.sessionMark, recipients: compose.recipients, form: compose.form, subjectField: compose.subjectField, bodyField: compose.bodyField };
190
+ const collated = compose.form.checkboxes?.find((c) => c.name === COLLATED);
191
+ return {
192
+ kind: "message",
193
+ sessionMark: session.sessionMark,
194
+ recipients: compose.recipients,
195
+ form: compose.form,
196
+ subjectField: compose.subjectField,
197
+ bodyField: compose.bodyField,
198
+ collatedReplies: collated ? collated.checked : null,
199
+ };
189
200
  }
190
- /** Send the message (once). */
201
+ /**
202
+ * Send the message (once). `collatedReplies` ticks or clears Wilma's "recipients see each other's replies" box
203
+ * when the form has one; left out, the box stays as Wilma's form has it.
204
+ */
191
205
  export async function sendMessage(client, prepared, input) {
192
206
  const session = sessionFor(client);
193
207
  if (prepared.kind !== "message")
@@ -197,7 +211,12 @@ export async function sendMessage(client, prepared, input) {
197
211
  if (!input.text.trim())
198
212
  throw new TypeError("The message is empty");
199
213
  const before = await sentState(client, null);
200
- let fields = setField(prepared.form.fields, prepared.subjectField, input.subject);
214
+ let fields = prepared.form.fields;
215
+ const box = prepared.form.checkboxes?.find((c) => c.name === COLLATED);
216
+ if (box && input.collatedReplies !== undefined) {
217
+ fields = input.collatedReplies ? setField(fields, box.name, box.value) : fields.filter(([name]) => name !== box.name);
218
+ }
219
+ fields = setField(fields, prepared.subjectField, input.subject);
201
220
  fields = withButton(setField(fields, prepared.bodyField, editorBody(prepared.form, input.text)), prepared.form, "addsavebtn");
202
221
  sameSession(session, prepared);
203
222
  const answer = await read(await session.submit(prepared.form.action, fields), "The message");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-client",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Unofficial client for Wilma, the Finnish school information system: login (with two-step verification), schedule, homework, exams, messages and news.",
5
5
  "license": "MIT",
6
6
  "repository": {