@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.
@@ -1,4 +1,5 @@
1
1
  import * as cheerio from "cheerio";
2
+ import { htmlToText } from "./html-text.js";
2
3
  export function parseExamsHtml(html) {
3
4
  const $ = cheerio.load(html);
4
5
  const exams = [];
@@ -76,6 +77,96 @@ export function parseExamsHtml(html) {
76
77
  });
77
78
  return exams;
78
79
  }
80
+ /**
81
+ * Wilma's past exams page (Kokeet > Menneet): a table with a row per exam,
82
+ * columns seen, date, teachers, exam, notes, grade and verbal assessment. The
83
+ * exam cell reads "name : group course-code : course title", e.g.
84
+ * "Verbikoe 4 : RUB1_ Ruotsin kieli, B1.t RUB1_91 : Ruotsin kieli, B1".
85
+ * Columns are found by their headings (Finnish, Swedish or English), or by
86
+ * Wilma's usual order. Newest first.
87
+ */
88
+ export function parsePastExamsHtml(html) {
89
+ const $ = cheerio.load(html);
90
+ const table = $("table#examtable").first().length ? $("table#examtable").first() : $("main table").first();
91
+ if (!table.length)
92
+ return [];
93
+ const headings = table.find("tr").first().find("th").toArray().map((th) => $(th).text().trim().toLowerCase());
94
+ const column = (pattern, fallback) => {
95
+ const at = headings.findIndex((h) => pattern.test(h));
96
+ return at >= 0 ? at : fallback;
97
+ };
98
+ const col = {
99
+ seen: column(/nähty|sedd|seen/, 0),
100
+ date: column(/^pvm|päivä|datum|date/, 1),
101
+ teacher: column(/opettaja|lärare|teacher/, 2),
102
+ exam: column(/^(koe|prov|exam)$/, 3),
103
+ info: column(/lisätied|tilläggs|additional|info/, 4),
104
+ grade: column(/^(arvosana|vitsord|grade)$/, 5),
105
+ verbal: column(/sanallinen|verbal/, 6),
106
+ };
107
+ const isoDate = (text) => {
108
+ const m = /(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(text);
109
+ return m ? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}` : null;
110
+ };
111
+ const exams = [];
112
+ table.find("tr").each((_, row) => {
113
+ const cells = $(row).children("td").toArray();
114
+ if (cells.length < 6)
115
+ return;
116
+ const cell = (i) => (cells[i] ? $(cells[i]) : null);
117
+ const date = isoDate(cell(col.date)?.text() ?? "");
118
+ if (!date)
119
+ return;
120
+ const { name, subject, subjectCode } = splitPastExamTitle(compactText(cell(col.exam)?.text() ?? ""));
121
+ const links = (cell(col.teacher)?.find("a.profile-link").toArray() ?? []).map((a) => $(a));
122
+ const teacherText = compactText(cell(col.teacher)?.text() ?? "");
123
+ const grade = compactText(cell(col.grade)?.text() ?? "");
124
+ const info = htmlToText(cell(col.info)?.html());
125
+ const verbal = htmlToText(cell(col.verbal)?.html());
126
+ exams.push({
127
+ date,
128
+ name,
129
+ subject,
130
+ subjectCode,
131
+ grade: grade || null,
132
+ verbalGrade: verbal || null,
133
+ info: info || null,
134
+ teacher: links.length ? links.map((a) => compactText(a.attr("title") || a.text())).join(", ") : teacherText,
135
+ teacherCode: links.length ? links.map((a) => compactText(a.text())).join(", ") : "",
136
+ seen: isoDate(cell(col.seen)?.text() ?? ""),
137
+ });
138
+ });
139
+ // Newest first; the order Wilma gave within a day.
140
+ return exams.map((exam, i) => ({ exam, i })).sort((a, b) => b.exam.date.localeCompare(a.exam.date) || a.i - b.i).map((x) => x.exam);
141
+ }
142
+ const COURSE_CODE = /^[\p{Lu}\d]+_[\p{L}\d]*$/u;
143
+ /** "RUB1_ Ruotsin kieli, B1.t RUB1_91": a group and its course, ending in the course code; that code, else null. */
144
+ function courseCodeOf(part) {
145
+ const words = part.trim().split(/\s+/);
146
+ return words.length >= 2 && COURSE_CODE.test(words[0]) && COURSE_CODE.test(words[words.length - 1]) ? words[words.length - 1] : null;
147
+ }
148
+ /**
149
+ * "Name : GROUP_ Group title CODE_91 : Course title" → its name, course title
150
+ * and code. Without a name Wilma writes only "GROUP_ Group title CODE_91 :
151
+ * Course title"; without a course, "Name : Course title".
152
+ */
153
+ function splitPastExamTitle(text) {
154
+ const parts = text.split(" : ");
155
+ if (parts.length >= 3) {
156
+ const middle = parts[parts.length - 2].trim().split(/\s+/);
157
+ const last = middle[middle.length - 1] ?? "";
158
+ return {
159
+ name: parts.slice(0, -2).join(" : ").trim(),
160
+ subject: parts[parts.length - 1].trim(),
161
+ subjectCode: COURSE_CODE.test(last) ? last : "",
162
+ };
163
+ }
164
+ if (parts.length === 2) {
165
+ const code = courseCodeOf(parts[0]);
166
+ return code ? { name: "", subject: parts[1].trim(), subjectCode: code } : { name: parts[0].trim(), subject: parts[1].trim(), subjectCode: "" };
167
+ }
168
+ return { name: text, subject: "", subjectCode: "" };
169
+ }
79
170
  /**
80
171
  * Add start times from the exam calendar to upcoming exams (the front page's
81
172
  * exam list has dates only). An exam is matched by date and its course code
@@ -0,0 +1,40 @@
1
+ import type { CheerioAPI, Cheerio, load } from "cheerio";
2
+ /** A parsed node (domhandler's AnyNode, without depending on domhandler). */
3
+ export type AnyNode = Extract<Parameters<typeof load>[0], {
4
+ type: unknown;
5
+ }>;
6
+ export interface WilmaForm {
7
+ /** Where the form posts: a path on this Wilma. */
8
+ action: string;
9
+ /** Name/value pairs a browser would send, without any submit button. */
10
+ fields: [string, string][];
11
+ /** The form's submit buttons, in page order. */
12
+ buttons: {
13
+ name: string;
14
+ value: string;
15
+ label: string;
16
+ }[];
17
+ selects: {
18
+ name: string;
19
+ options: {
20
+ value: string;
21
+ label: string;
22
+ selected: boolean;
23
+ }[];
24
+ }[];
25
+ textareas: string[];
26
+ }
27
+ export declare class FormOutsideWilmaError extends Error {
28
+ constructor();
29
+ }
30
+ /** A path on this Wilma for a link or form action on a page at `pageUrl`. */
31
+ export declare function wilmaPath(href: string, pageUrl: string): string;
32
+ export declare function readForm($: CheerioAPI, form: Cheerio<AnyNode>, pageUrl: string): WilmaForm;
33
+ /** The form key on a page (Wilma's anti-forgery token), preferring one outside the logout form. */
34
+ export declare function pageFormkey($: CheerioAPI): string | null;
35
+ /** Set a field's value, replacing every earlier value of that name (or adding it). */
36
+ export declare function setField(fields: [string, string][], name: string, value: string): [string, string][];
37
+ /** The text of Wilma's error messages on a page (after a refused form), if any. */
38
+ export declare function pageErrors($: CheerioAPI): string | null;
39
+ /** Wilma's success and info notices on a page, one string each. */
40
+ export declare function pageNotices($: CheerioAPI): string[];
@@ -0,0 +1,93 @@
1
+ export class FormOutsideWilmaError extends Error {
2
+ constructor() {
3
+ super("Wilma's form points outside Wilma; nothing was sent");
4
+ this.name = "FormOutsideWilmaError";
5
+ }
6
+ }
7
+ /** A path on this Wilma for a link or form action on a page at `pageUrl`. */
8
+ export function wilmaPath(href, pageUrl) {
9
+ const page = new URL(pageUrl);
10
+ const url = new URL(href || pageUrl, pageUrl);
11
+ if (url.origin !== page.origin)
12
+ throw new FormOutsideWilmaError();
13
+ return url.pathname + url.search;
14
+ }
15
+ const clean = (text) => text.replace(/\s+/g, " ").trim();
16
+ export function readForm($, form, pageUrl) {
17
+ const fields = [];
18
+ const buttons = [];
19
+ const selects = [];
20
+ const textareas = [];
21
+ form.find("input, select, textarea, button").each((_, el) => {
22
+ const control = $(el);
23
+ const name = control.attr("name");
24
+ if (!name || control.attr("disabled") !== undefined)
25
+ return;
26
+ const tag = el.tagName?.toLowerCase();
27
+ if (tag === "select") {
28
+ const options = control
29
+ .find("option")
30
+ .map((_, o) => ({
31
+ value: $(o).attr("value") ?? clean($(o).text()),
32
+ label: clean($(o).text()),
33
+ selected: $(o).attr("selected") !== undefined,
34
+ }))
35
+ .get();
36
+ selects.push({ name, options });
37
+ const chosen = options.find((o) => o.selected) ?? (control.attr("multiple") === undefined ? options[0] : undefined);
38
+ if (chosen)
39
+ fields.push([name, chosen.value]);
40
+ return;
41
+ }
42
+ if (tag === "textarea") {
43
+ textareas.push(name);
44
+ fields.push([name, control.text()]);
45
+ return;
46
+ }
47
+ const type = (control.attr("type") ?? (tag === "button" ? "submit" : "text")).toLowerCase();
48
+ const value = control.attr("value") ?? "";
49
+ if (type === "submit" || type === "image") {
50
+ buttons.push({ name, value, label: clean(control.text()) || value });
51
+ }
52
+ else if (type === "checkbox" || type === "radio") {
53
+ if (control.attr("checked") !== undefined)
54
+ fields.push([name, control.attr("value") ?? "on"]);
55
+ }
56
+ else if (type !== "file" && type !== "reset" && type !== "button") {
57
+ fields.push([name, value]);
58
+ }
59
+ });
60
+ return { action: wilmaPath(form.attr("action") ?? "", pageUrl), fields, buttons, selects, textareas };
61
+ }
62
+ /** The form key on a page (Wilma's anti-forgery token), preferring one outside the logout form. */
63
+ export function pageFormkey($) {
64
+ const keys = $('input[name="formkey"]')
65
+ .filter((_, el) => $(el).closest("#logout-form").length === 0)
66
+ .map((_, el) => $(el).attr("value") ?? "")
67
+ .get()
68
+ .filter(Boolean);
69
+ return keys[0] ?? ($('#logout-form input[name="formkey"]').attr("value") || null);
70
+ }
71
+ /** Set a field's value, replacing every earlier value of that name (or adding it). */
72
+ export function setField(fields, name, value) {
73
+ const out = fields.filter(([n]) => n !== name);
74
+ const at = fields.findIndex(([n]) => n === name);
75
+ out.splice(at === -1 ? out.length : at, 0, [name, value]);
76
+ return out;
77
+ }
78
+ /** The text of Wilma's error messages on a page (after a refused form), if any. */
79
+ export function pageErrors($) {
80
+ const text = $(".alert-danger, .alert-error, .has-error .help-block, .error-message")
81
+ .map((_, el) => clean($(el).text()))
82
+ .get()
83
+ .filter(Boolean)
84
+ .join(" ");
85
+ return text ? text.slice(0, 300) : null;
86
+ }
87
+ /** Wilma's success and info notices on a page, one string each. */
88
+ export function pageNotices($) {
89
+ return $(".alert-success, .alert-info, .notification-success")
90
+ .map((_, el) => clean($(el).text()))
91
+ .get()
92
+ .filter(Boolean);
93
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Readable text from a fragment of Wilma HTML (message and reply bodies):
3
- * paragraphs and line breaks kept, list items bulleted, and links written as
4
- * "label (address)" so the address isn't lost.
2
+ * Readable text from a fragment of Wilma HTML (message, reply and bulletin
3
+ * bodies): paragraphs and line breaks kept, list items bulleted ("- ", or
4
+ * "1. " in a numbered list) and indented two spaces per nested level, and
5
+ * links written as "label (address)" so the address isn't lost.
5
6
  */
6
7
  export declare function htmlToText(html: string | null | undefined): string;
@@ -1,14 +1,18 @@
1
1
  import * as cheerio from "cheerio";
2
2
  const BLOCKS = "p, div, tr, h1, h2, h3, h4, h5, h6, blockquote, table, ul, ol";
3
+ /** Stands in for a nested list item's indent until spaces are tidied (then two spaces per level). */
4
+ const INDENT = "\u0002";
3
5
  /**
4
- * Readable text from a fragment of Wilma HTML (message and reply bodies):
5
- * paragraphs and line breaks kept, list items bulleted, and links written as
6
- * "label (address)" so the address isn't lost.
6
+ * Readable text from a fragment of Wilma HTML (message, reply and bulletin
7
+ * bodies): paragraphs and line breaks kept, list items bulleted ("- ", or
8
+ * "1. " in a numbered list) and indented two spaces per nested level, and
9
+ * links written as "label (address)" so the address isn't lost.
7
10
  */
8
11
  export function htmlToText(html) {
9
12
  if (!html)
10
13
  return "";
11
- const $ = cheerio.load(`<div id="wilmai-root">${html}</div>`);
14
+ // Wilma's editor writes "<br>" and then a newline in the source: one line break, not two.
15
+ const $ = cheerio.load(`<div id="wilmai-root">${html.replace(/<br\s*\/?>[ \t]*\r?\n/gi, "<br>")}</div>`);
12
16
  const root = $("#wilmai-root");
13
17
  root.find("script, style").remove();
14
18
  root.find("a[href]").each((_, anchor) => {
@@ -24,19 +28,46 @@ export function htmlToText(html) {
24
28
  link.text(`${label} (${address})`);
25
29
  });
26
30
  root.find("br").replaceWith("\n");
27
- root.find("li").each((_, item) => {
28
- $(item).prepend("- ");
29
- $(item).append("\n");
31
+ // The source's line breaks and tabs around list items aren't text (they made blank lines).
32
+ root.find("ul, ol, li").each((_, el) => {
33
+ $(el)
34
+ .contents()
35
+ .filter((_, node) => node.type === "text" && !$(node).text().trim())
36
+ .remove();
37
+ });
38
+ root.find("li > ul, li > ol").each((_, list) => {
39
+ const before = list.prev;
40
+ if (before?.type === "text")
41
+ before.data = before.data.replace(/\s+$/, "");
30
42
  });
31
43
  root.find(BLOCKS).each((_, block) => {
32
- $(block).prepend("\n");
33
- $(block).append("\n");
44
+ const el = $(block);
45
+ // A list inside an item starts on the next line; the item ends where the list does.
46
+ if (el.is("ul, ol") && el.parents("li").length) {
47
+ el.prepend("\n");
48
+ return;
49
+ }
50
+ // A paragraph that opens or closes an item stays on the bullet's line.
51
+ const inItem = el.parent().is("li") && el.is("p, div");
52
+ if (!(inItem && el.prev().length === 0))
53
+ el.prepend("\n");
54
+ if (!(inItem && el.next().length === 0))
55
+ el.append("\n");
56
+ });
57
+ root.find("li").each((_, item) => {
58
+ const li = $(item);
59
+ const list = li.parent();
60
+ const marker = list.is("ol") ? `${list.children("li").index(li) + (Number(list.attr("start")) || 1)}. ` : "- ";
61
+ li.prepend(INDENT.repeat(li.parents("li").length) + marker);
62
+ if (!li.contents().last().is("ul, ol"))
63
+ li.append("\n");
34
64
  });
35
65
  return root
36
66
  .text()
37
- .replace(/ /g, " ")
67
+ .replace(/\u00a0/g, " ")
38
68
  .replace(/[ \t]+/g, " ")
39
69
  .replace(/ *\n */g, "\n")
40
70
  .replace(/\n{3,}/g, "\n\n")
71
+ .replace(new RegExp(INDENT, "g"), " ")
41
72
  .trim();
42
73
  }
@@ -0,0 +1,13 @@
1
+ import type { Invitation, InvitationListing, InvitationSlot, InvitationSlotState, Message } from "../types.js";
2
+ /** A message's EventData (a JSON string or already parsed); null when it isn't an invitation. */
3
+ export declare function parseInvitation(raw: unknown): Invitation | null;
4
+ /**
5
+ * What can be done with a slot right now (the states Wilma's own page shows).
6
+ * Pass the invitation to tell a free slot from one the family can't take
7
+ * because it already has another.
8
+ */
9
+ export declare function slotState(slot: InvitationSlot, now?: Date, invitation?: Invitation): InvitationSlotState;
10
+ /** The invitation still waits for an answer: nobody in the family signed up or said they won't attend, and a slot is open. */
11
+ export declare function awaitingAnswer(invitation: Invitation, now?: Date): boolean;
12
+ /** The invitations list: each message with where this family stands. */
13
+ export declare function parseInvitationListings(data: unknown, messages: Message[]): InvitationListing[];
@@ -0,0 +1,121 @@
1
+ /*
2
+ * Event invitations (Tapahtumakutsut): parents' evenings, learning
3
+ * discussions with time slots, a choice between events.
4
+ *
5
+ * Seen 2026-10-06 (Helsinki, Wilma 2.36):
6
+ * - list: `/messages/list/appointments`, the inbox JSON plus IsEvent and
7
+ * Applying {Status: "Applied" | "ExpiredReg" | …, ExtraInfo};
8
+ * - detail: `/messages/<id>?format=json` with EventData, a JSON string
9
+ * {MustApply, Events: [{EventID, Start, End, EnrollStart, EnrollEnd,
10
+ * CancelDate, Info, MaxPeople, PeopleCount, State, CanDismissEvent,
11
+ * IsDismissed}]} (a string, or already an object on some messages).
12
+ *
13
+ * State (2026-10-08, the class of the button Wilma's page shows for it):
14
+ * "available"; "selected-cancellable" for your slot while it can be
15
+ * cancelled, "selected" once it can't ("Perumisaika on umpeutunut");
16
+ * "selected-other" with SelectedOther naming the guardian who signed up;
17
+ * "full"; "pending" before sign-up opens; "expired" after it closes.
18
+ * CanDismissEvent: the page offers "En osallistu" (won't attend), which
19
+ * covers every such slot; IsDismissed: the family said so.
20
+ */
21
+ import { finnishTime } from "../finnish-time.js";
22
+ /** "2026-10-14 17:30" (Finnish time) as an instant; null when there's no date. */
23
+ function wilmaTime(value) {
24
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(String(value ?? ""));
25
+ if (!m)
26
+ return null;
27
+ return finnishTime(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0));
28
+ }
29
+ /** A message's EventData (a JSON string or already parsed); null when it isn't an invitation. */
30
+ export function parseInvitation(raw) {
31
+ let data = raw;
32
+ if (typeof raw === "string") {
33
+ try {
34
+ data = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ const d = data;
41
+ if (!d || typeof d !== "object" || !Array.isArray(d.Events))
42
+ return null;
43
+ const slots = d.Events.flatMap((e) => {
44
+ if (!e || typeof e !== "object" || e.EventID === undefined || e.EventID === null)
45
+ return [];
46
+ return [
47
+ {
48
+ id: String(e.EventID),
49
+ start: wilmaTime(e.Start),
50
+ end: wilmaTime(e.End),
51
+ enrollStart: wilmaTime(e.EnrollStart),
52
+ enrollEnd: wilmaTime(e.EnrollEnd),
53
+ cancelUntil: wilmaTime(e.CancelDate),
54
+ info: String(e.Info ?? "").trim(),
55
+ subject: String(e.Subject ?? "").trim(),
56
+ maxPeople: Number(e.MaxPeople) || 0,
57
+ peopleCount: Number(e.PeopleCount) || 0,
58
+ selected: e.State === "selected" || e.State === "selected-cancellable",
59
+ selectedByOther: e.State === "selected-other" ? String(e.SelectedOther ?? "").trim() || "another guardian" : null,
60
+ wilmaState: typeof e.State === "string" ? e.State : "",
61
+ canDecline: e.CanDismissEvent === true,
62
+ declined: e.IsDismissed === true,
63
+ },
64
+ ];
65
+ });
66
+ const declinable = slots.filter((slot) => slot.canDecline);
67
+ return {
68
+ mustApply: Boolean(d.MustApply),
69
+ slots,
70
+ // As Wilma's page: the button shows while a slot can be declined and none is chosen for good.
71
+ canDecline: declinable.length > 0 && !slots.some((slot) => slot.wilmaState === "selected"),
72
+ declined: declinable.length > 0 && declinable.every((slot) => slot.declined),
73
+ };
74
+ }
75
+ /**
76
+ * What can be done with a slot right now (the states Wilma's own page shows).
77
+ * Pass the invitation to tell a free slot from one the family can't take
78
+ * because it already has another.
79
+ */
80
+ export function slotState(slot, now = new Date(), invitation) {
81
+ if (slot.selected)
82
+ return slot.wilmaState === "selected" || (slot.cancelUntil && now > slot.cancelUntil) ? "chosen-locked" : "chosen";
83
+ if (slot.selectedByOther)
84
+ return "chosen-by-other";
85
+ if (slot.wilmaState === "expired")
86
+ return "closed";
87
+ if (slot.wilmaState === "full")
88
+ return "full";
89
+ if (slot.wilmaState === "pending")
90
+ return "not-yet";
91
+ if (slot.enrollStart && now < slot.enrollStart)
92
+ return "not-yet";
93
+ if (slot.enrollEnd && now > slot.enrollEnd)
94
+ return "closed";
95
+ if (slot.maxPeople > 0 && slot.peopleCount >= slot.maxPeople)
96
+ return "full";
97
+ if (invitation && answered(invitation))
98
+ return "taken";
99
+ return "open";
100
+ }
101
+ /** You or another guardian signed up for one of the slots. */
102
+ function answered(invitation) {
103
+ return invitation.slots.some((slot) => slot.selected || slot.selectedByOther);
104
+ }
105
+ /** The invitation still waits for an answer: nobody in the family signed up or said they won't attend, and a slot is open. */
106
+ export function awaitingAnswer(invitation, now = new Date()) {
107
+ if (answered(invitation) || invitation.declined)
108
+ return false;
109
+ return invitation.slots.some((slot) => slotState(slot, now) === "open");
110
+ }
111
+ /** The invitations list: each message with where this family stands. */
112
+ export function parseInvitationListings(data, messages) {
113
+ const list = data ?? {};
114
+ const raw = (Array.isArray(list.Messages) ? list.Messages : Array.isArray(list.messages) ? list.messages : []);
115
+ const byId = new Map(raw.filter((m) => m && typeof m === "object").map((m) => [Number(m["Id"] ?? m["id"]), m]));
116
+ return messages.map((message) => {
117
+ const applying = byId.get(message.wilmaId)?.["Applying"];
118
+ const text = (value) => (typeof value === "string" && value.trim() ? value.replace(/\s+/g, " ").trim() : null);
119
+ return { message, status: text(applying?.Status), extra: text(applying?.ExtraInfo) };
120
+ });
121
+ }
@@ -1,6 +1,7 @@
1
1
  import * as cheerio from "cheerio";
2
2
  import { parseWilmaTimestamp } from "./dates.js";
3
3
  import { htmlToText } from "./html-text.js";
4
+ import { parseInvitation } from "./invitations.js";
4
5
  export function parseMessagesList(data, folder) {
5
6
  const now = new Date();
6
7
  const list = normalizeMessagesList(data);
@@ -89,6 +90,9 @@ export function parseMessageDetailJson(data, messageId) {
89
90
  content: htmlToText(str(m["ContentHtml"])),
90
91
  recipients,
91
92
  replies,
93
+ discussion: m["AllowCollatedReply"] === true,
94
+ canReply: typeof m["AllowReply"] === "boolean" ? m["AllowReply"] : null,
95
+ invitation: m["IsEvent"] ? parseInvitation(m["EventData"]) : null,
92
96
  fetchedAt: new Date(),
93
97
  };
94
98
  }
@@ -1,4 +1,5 @@
1
1
  import * as cheerio from "cheerio";
2
+ import { htmlToText } from "./html-text.js";
2
3
  import { parseWilmaTimestamp } from "./dates.js";
3
4
  const FILE_EXTENSION_RE = /\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|zip|7z|png|jpe?g|gif|webp)$/i;
4
5
  export function parseNewsList(data) {
@@ -29,12 +30,14 @@ export function parseNewsList(data) {
29
30
  }
30
31
  export function parseNewsDetailJson(newsId, data, baseUrl) {
31
32
  const rawContent = data["content"] ?? data["Content"];
32
- const content = typeof rawContent === "string" ? rawContent : null;
33
+ const html = typeof rawContent === "string" ? rawContent : null;
33
34
  let resources = [];
34
- if (content && content.includes("<a")) {
35
- const $ = cheerio.load(content);
35
+ if (html && html.includes("<a")) {
36
+ const $ = cheerio.load(html);
36
37
  resources = extractNewsResources($, $.root(), baseUrl);
37
38
  }
39
+ // The body is the editor's HTML: as text with its paragraphs and lists.
40
+ const content = html ? htmlToText(html) || null : null;
38
41
  return {
39
42
  wilmaId: newsId,
40
43
  title: String(data["title"] ?? data["Title"] ?? ""),
@@ -70,7 +73,8 @@ export function parseNewsDetailHtml(html, newsId, baseUrl) {
70
73
  resources = extractNewsResources($, contentElem, baseUrl);
71
74
  const cleanContent = contentElem.clone();
72
75
  cleanContent.find("script, style").remove();
73
- const text = cleanContent.text().trim();
76
+ // As text with its paragraphs and lists (the page's own indentation and spacer lines aren't text).
77
+ const text = htmlToText(cleanContent.html());
74
78
  // A container made entirely from resource anchors has no prose body. Keep
75
79
  // the labels in resources instead of duplicating them in content.
76
80
  const proseOnly = cleanContent.clone();
@@ -120,6 +120,7 @@ function parseTopLevelGrades(exams) {
120
120
  info: exam.Info?.trim() || null,
121
121
  teacher: teacher?.TeacherName ?? "",
122
122
  teacherCode: teacher?.TeacherCode ?? "",
123
+ seen: /^\d{4}-\d{2}-\d{2}/.exec(exam.ExamSeen ?? "")?.[0] ?? null,
123
124
  });
124
125
  }
125
126
  grades.sort((a, b) => b.date.localeCompare(a.date));
@@ -0,0 +1,16 @@
1
+ import type { MessageRecipient, StudentDetails } from "../types.js";
2
+ /** A compose link as a path on this Wilma, or null for anything else. */
3
+ export declare function composePath(href: string | undefined, pageUrl: string): string | null;
4
+ /** What a compose link writes to, from its r_ parameters (a repeated or mixed one: several people). */
5
+ export declare function recipientKind(path: string): MessageRecipient["kind"];
6
+ /** The child's class and supervisors from their front page (`/!<n>/`); empty fields when the panel isn't there. */
7
+ export declare function parseStudentDetailsHtml(html: string, pageUrl: string): StudentDetails;
8
+ /**
9
+ * The recipients page (`/messages/recipients`), or a list it loads (own
10
+ * teachers). Only recipients Wilma lets the parent write to are returned.
11
+ */
12
+ export declare function parseRecipientsHtml(html: string, pageUrl: string, defaults?: {
13
+ group?: string;
14
+ }): MessageRecipient[];
15
+ /** Lists the recipients page loads on demand (own teachers one by one): [panel title, path]. */
16
+ export declare function recipientListSources(html: string, pageUrl: string): [string, string][];