@wilm-ai/wilma-client 1.5.2 → 1.6.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.
@@ -0,0 +1,44 @@
1
+ import * as cheerio from "cheerio";
2
+ /**
3
+ * The gradebook (Suoritukset): subjects, their syllabi and courses, each with
4
+ * a grade, scope and completion date. Rows carry their depth as a class
5
+ * ("level1", "level2", …), which gives the tree. An empty gradebook (nothing
6
+ * graded yet) has no table and gives [].
7
+ */
8
+ export function parseGradebookHtml(html) {
9
+ const $ = cheerio.load(html);
10
+ const roots = [];
11
+ const stack = [];
12
+ $("table tbody tr").each((_, row) => {
13
+ const depthMatch = /\blevel(\d+)\b/.exec($(row).attr("class") ?? "");
14
+ const cells = $(row).find("td");
15
+ if (!depthMatch || cells.length < 2)
16
+ return;
17
+ const depth = Number(depthMatch[1]);
18
+ const text = (index) => $(cells[index]).text().replace(/\s+/g, " ").trim();
19
+ const label = text(0);
20
+ if (!label)
21
+ return;
22
+ const codeMatch = /^([\p{L}\d]+_[\p{L}\d.]*)\s+(.*)$/u.exec(label);
23
+ const entry = {
24
+ name: codeMatch ? codeMatch[2] : label,
25
+ code: codeMatch ? codeMatch[1] : null,
26
+ grade: text(1) || null,
27
+ credits: cells.length > 3 ? text(2) || null : null,
28
+ date: isoDate(text(cells.length > 3 ? 3 : 2)),
29
+ children: [],
30
+ };
31
+ while (stack.length && stack[stack.length - 1].depth >= depth)
32
+ stack.pop();
33
+ if (stack.length)
34
+ stack[stack.length - 1].entry.children.push(entry);
35
+ else
36
+ roots.push(entry);
37
+ stack.push({ depth, entry });
38
+ });
39
+ return roots;
40
+ }
41
+ function isoDate(value) {
42
+ const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
43
+ return match ? `${match[3]}-${match[2].padStart(2, "0")}-${match[1].padStart(2, "0")}` : null;
44
+ }
@@ -0,0 +1,6 @@
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.
5
+ */
6
+ export declare function htmlToText(html: string | null | undefined): string;
@@ -0,0 +1,42 @@
1
+ import * as cheerio from "cheerio";
2
+ const BLOCKS = "p, div, tr, h1, h2, h3, h4, h5, h6, blockquote, table, ul, ol";
3
+ /**
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.
7
+ */
8
+ export function htmlToText(html) {
9
+ if (!html)
10
+ return "";
11
+ const $ = cheerio.load(`<div id="wilmai-root">${html}</div>`);
12
+ const root = $("#wilmai-root");
13
+ root.find("script, style").remove();
14
+ root.find("a[href]").each((_, anchor) => {
15
+ const link = $(anchor);
16
+ const href = (link.attr("href") ?? "").trim();
17
+ const label = link.text().replace(/\s+/g, " ").trim();
18
+ if (!/^(https?:|mailto:)/i.test(href))
19
+ return;
20
+ const address = href.replace(/^mailto:/i, "");
21
+ if (!label)
22
+ link.text(address);
23
+ else if (!label.includes(address) && !address.includes(label))
24
+ link.text(`${label} (${address})`);
25
+ });
26
+ root.find("br").replaceWith("\n");
27
+ root.find("li").each((_, item) => {
28
+ $(item).prepend("- ");
29
+ $(item).append("\n");
30
+ });
31
+ root.find(BLOCKS).each((_, block) => {
32
+ $(block).prepend("\n");
33
+ $(block).append("\n");
34
+ });
35
+ return root
36
+ .text()
37
+ .replace(/ /g, " ")
38
+ .replace(/[ \t]+/g, " ")
39
+ .replace(/ *\n */g, "\n")
40
+ .replace(/\n{3,}/g, "\n\n")
41
+ .trim();
42
+ }
@@ -1,5 +1,9 @@
1
1
  import type { Message, MessageFolder } from "../types.js";
2
2
  export declare function parseMessagesList(data: unknown, folder: MessageFolder): Message[];
3
+ /**
4
+ * A message thread from `/messages/<id>?format=json`: the message, who it went
5
+ * to, and every reply. Returns null when the data isn't a thread (older Wilma
6
+ * versions answer with the HTML page instead).
7
+ */
8
+ export declare function parseMessageDetailJson(data: unknown, messageId: number): Message | null;
3
9
  export declare function parseMessageDetailHtml(html: string, messageId: number): Message;
4
- /** Export for debugging - returns raw field names from first message */
5
- export declare function debugMessageFields(data: unknown): string[];
@@ -1,11 +1,15 @@
1
1
  import * as cheerio from "cheerio";
2
2
  import { parseWilmaTimestamp } from "./dates.js";
3
+ import { htmlToText } from "./html-text.js";
3
4
  export function parseMessagesList(data, folder) {
4
5
  const now = new Date();
5
6
  const list = normalizeMessagesList(data);
6
7
  return list.flatMap((item) => {
7
8
  try {
8
9
  const wilmaId = Number(item["id"] ?? item["Id"]);
10
+ // Without a usable id a message can't be opened; skip it rather than carry NaN.
11
+ if (!Number.isInteger(wilmaId) || wilmaId <= 0)
12
+ return [];
9
13
  const subject = compactText(String(item["Subject"] ?? item["subject"] ?? ""));
10
14
  // Try many possible field names for the timestamp
11
15
  const timeValue = item["Time"] ??
@@ -23,12 +27,17 @@ export function parseMessagesList(data, folder) {
23
27
  item["created"] ??
24
28
  item["CreatedAt"] ??
25
29
  item["createdAt"];
30
+ const sender = item["Sender"] ?? item["sender"];
26
31
  return [
27
32
  {
28
33
  wilmaId,
29
34
  subject,
30
35
  sentAt: parseWilmaTimestamp(timeValue),
31
36
  folder,
37
+ senderName: typeof sender === "string" && sender.trim() ? compactText(sender) : null,
38
+ // Wilma marks unopened messages with a truthy Status (shown in bold).
39
+ unread: Boolean(item["Status"]),
40
+ replyCount: Number(item["Replies"]) || 0,
32
41
  fetchedAt: now,
33
42
  },
34
43
  ];
@@ -38,6 +47,51 @@ export function parseMessagesList(data, folder) {
38
47
  }
39
48
  });
40
49
  }
50
+ /**
51
+ * A message thread from `/messages/<id>?format=json`: the message, who it went
52
+ * to, and every reply. Returns null when the data isn't a thread (older Wilma
53
+ * versions answer with the HTML page instead).
54
+ */
55
+ export function parseMessageDetailJson(data, messageId) {
56
+ const list = data?.messages;
57
+ const m = Array.isArray(list) ? list[0] : undefined;
58
+ if (!m || typeof m !== "object")
59
+ return null;
60
+ const str = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
61
+ const recipients = Array.isArray(m["Recipients"])
62
+ ? m["Recipients"].map(str).filter((r) => Boolean(r))
63
+ : str(m["Recipient"])
64
+ ? [str(m["Recipient"])]
65
+ : null;
66
+ const replies = Array.isArray(m["ReplyList"])
67
+ ? m["ReplyList"].flatMap((reply) => {
68
+ if (!reply || typeof reply !== "object")
69
+ return [];
70
+ return [
71
+ {
72
+ id: Number(reply["Id"]) || 0,
73
+ sentAt: parseWilmaTimestamp(reply["TimeStamp"]),
74
+ senderName: str(reply["Sender"]),
75
+ content: htmlToText(str(reply["ContentHtml"])),
76
+ },
77
+ ];
78
+ })
79
+ : [];
80
+ replies.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
81
+ return {
82
+ wilmaId: Number(m["Id"]) || messageId,
83
+ subject: compactText(String(m["Subject"] ?? "")),
84
+ sentAt: parseWilmaTimestamp(m["TimeStamp"]),
85
+ folder: String(m["Folder"] ?? "unknown"),
86
+ senderId: typeof m["SenderId"] === "number" ? m["SenderId"] : null,
87
+ senderType: typeof m["SenderType"] === "number" ? m["SenderType"] : null,
88
+ senderName: str(m["Sender"]),
89
+ content: htmlToText(str(m["ContentHtml"])),
90
+ recipients,
91
+ replies,
92
+ fetchedAt: new Date(),
93
+ };
94
+ }
41
95
  export function parseMessageDetailHtml(html, messageId) {
42
96
  const $ = cheerio.load(html);
43
97
  const panelBody = $("div#page-content-area.panel-body");
@@ -171,14 +225,6 @@ function normalizeMessagesList(data) {
171
225
  }
172
226
  return [];
173
227
  }
174
- /** Export for debugging - returns raw field names from first message */
175
- export function debugMessageFields(data) {
176
- const list = normalizeMessagesList(data);
177
- if (list.length > 0) {
178
- return Object.keys(list[0]);
179
- }
180
- return [];
181
- }
182
228
  function compactText(value) {
183
229
  return value.replace(/\s+/g, " ").trim();
184
230
  }
@@ -7,6 +7,8 @@ export function parseNewsList(data) {
7
7
  return data.flatMap((item) => {
8
8
  try {
9
9
  const wilmaId = Number(item["id"] ?? item["Id"]);
10
+ if (!Number.isInteger(wilmaId) || wilmaId <= 0)
11
+ return [];
10
12
  const title = String(item["Title"] ?? item["title"] ?? "");
11
13
  const published = parseWilmaTimestamp(item["Published"] ?? item["published"]);
12
14
  return [
@@ -26,7 +28,8 @@ export function parseNewsList(data) {
26
28
  return [];
27
29
  }
28
30
  export function parseNewsDetailJson(newsId, data, baseUrl) {
29
- const content = (data["content"] ?? data["Content"]);
31
+ const rawContent = data["content"] ?? data["Content"];
32
+ const content = typeof rawContent === "string" ? rawContent : null;
30
33
  let resources = [];
31
34
  if (content && content.includes("<a")) {
32
35
  const $ = cheerio.load(content);
@@ -35,7 +38,7 @@ export function parseNewsDetailJson(newsId, data, baseUrl) {
35
38
  return {
36
39
  wilmaId: newsId,
37
40
  title: String(data["title"] ?? data["Title"] ?? ""),
38
- subtitle: (data["subtitle"] ?? data["Subtitle"]),
41
+ subtitle: typeof (data["subtitle"] ?? data["Subtitle"]) === "string" ? (data["subtitle"] ?? data["Subtitle"]) : null,
39
42
  author: (data["author"] ?? data["Author"]),
40
43
  published: parseWilmaTimestamp(data["Published"] ?? data["published"]),
41
44
  content,
@@ -74,17 +77,31 @@ export function parseNewsDetailHtml(html, newsId, baseUrl) {
74
77
  proseOnly.find("a[href]").remove();
75
78
  content = proseOnly.text().trim() ? text : null;
76
79
  }
80
+ // "Sär (Teacher Name) Julkaistu 27.9." — the date (this year's lack the
81
+ // year) and the author's profile link sit together under the bulletin.
82
+ let published = null;
83
+ let author = null;
84
+ const meta = $("p, span, div")
85
+ .filter((_, el) => PUBLISHED_RE.test($(el).text()) && $(el).find("p, div").length === 0)
86
+ .first();
87
+ const publishedMatch = PUBLISHED_RE.exec(meta.text().replace(/\s+/g, " "));
88
+ if (publishedMatch) {
89
+ published = parseWilmaTimestamp(publishedMatch[1]);
90
+ const authorLink = meta.find("a.profile-link").first();
91
+ author = authorLink.attr("title")?.trim() || authorLink.text().trim() || null;
92
+ }
77
93
  return {
78
94
  wilmaId: newsId,
79
95
  title,
80
96
  subtitle,
81
- author: null,
82
- published: null,
97
+ author,
98
+ published,
83
99
  content,
84
100
  resources,
85
101
  fetchedAt: new Date(),
86
102
  };
87
103
  }
104
+ const PUBLISHED_RE = /(?:Julkaistu|Publicerad|Published)\s*:?\s*(\d{1,2}\.\d{1,2}\.(?:\d{4})?)/i;
88
105
  function extractNewsResources($, contentElem, baseUrl) {
89
106
  const resources = [];
90
107
  const seen = new Set();
@@ -143,9 +160,6 @@ export function parseNewsListHtml(html) {
143
160
  const newsItems = [];
144
161
  const seenIds = new Set();
145
162
  const headers = $("div.left h2.no-border, h2.no-border");
146
- if (!headers.length) {
147
- return newsItems;
148
- }
149
163
  headers.each((_, header) => {
150
164
  const headerEl = $(header);
151
165
  const timestampRaw = headerEl.text().trim();
@@ -202,11 +216,51 @@ export function parseNewsListHtml(html) {
202
216
  subtitle,
203
217
  author,
204
218
  published,
219
+ // A padlock icon marks a bulletin pinned to the page.
220
+ pinned: container.find(".vismaicon-locked").length > 0,
221
+ archived: false,
205
222
  fetchedAt: now,
206
223
  });
207
224
  }
208
225
  sib = sib.next();
209
226
  }
210
227
  });
228
+ // The side panels list pinned bulletins and older ones (titles only; the
229
+ // date is on each bulletin). Recognised by their heading, in Finnish,
230
+ // Swedish or English.
231
+ $("div.panel-body > h2").each((_, heading) => {
232
+ const label = $(heading).text().trim();
233
+ const pinned = SIDE_PANEL_PINNED.test(label);
234
+ const archived = SIDE_PANEL_OLDER.test(label);
235
+ if (!pinned && !archived)
236
+ return;
237
+ $(heading)
238
+ .parent()
239
+ .find("a[href*='/news/']")
240
+ .each((_, anchor) => {
241
+ const match = /\/news\/(\d+)/.exec($(anchor).attr("href") ?? "");
242
+ const newsId = match ? Number(match[1]) : null;
243
+ if (!newsId)
244
+ return;
245
+ const known = newsItems.find((item) => item.wilmaId === newsId);
246
+ if (known) {
247
+ if (pinned)
248
+ known.pinned = true;
249
+ return;
250
+ }
251
+ newsItems.push({
252
+ wilmaId: newsId,
253
+ title: $(anchor).text().trim() || "Untitled News",
254
+ subtitle: null,
255
+ author: null,
256
+ published: null,
257
+ pinned,
258
+ archived,
259
+ fetchedAt: now,
260
+ });
261
+ });
262
+ });
211
263
  return newsItems;
212
264
  }
265
+ const SIDE_PANEL_PINNED = /^(pysyvät|permanent|pinned|bestående|fasta|stående)/i;
266
+ const SIDE_PANEL_OLDER = /^(vanhat|vanhemmat|old|older|gamla|äldre|arkiv|archive)/i;
@@ -1,2 +1,3 @@
1
1
  import type { OverviewData } from "../types.js";
2
- export declare function parseOverview(raw: unknown): OverviewData;
2
+ /** `now` decides which exams are upcoming (default: the current time). */
3
+ export declare function parseOverview(raw: unknown, now?: Date): OverviewData;
@@ -1,3 +1,4 @@
1
+ import { finnishDateString } from "../finnish-time.js";
1
2
  /* ------------------------------------------------------------------ */
2
3
  /* Helpers */
3
4
  /* ------------------------------------------------------------------ */
@@ -23,10 +24,10 @@ function firstScheduleTeacher(teachers) {
23
24
  /* ------------------------------------------------------------------ */
24
25
  /* Main parser */
25
26
  /* ------------------------------------------------------------------ */
26
- export function parseOverview(raw) {
27
+ /** `now` decides which exams are upcoming (default: the current time). */
28
+ export function parseOverview(raw, now = new Date()) {
27
29
  const data = (raw ?? {});
28
- const now = new Date();
29
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
30
+ const today = finnishDateString(now);
30
31
  return {
31
32
  schedule: parseSchedule(data.Schedule ?? []),
32
33
  upcomingExams: parseUpcomingExams(data.Groups ?? [], today),
@@ -0,0 +1,3 @@
1
+ import type { Printout } from "../types.js";
2
+ /** Printouts (Tulosteet): PDF documents the school offers, e.g. report cards or absence reports. */
3
+ export declare function parsePrintoutsHtml(html: string): Printout[];
@@ -0,0 +1,20 @@
1
+ import * as cheerio from "cheerio";
2
+ /** Printouts (Tulosteet): PDF documents the school offers, e.g. report cards or absence reports. */
3
+ export function parsePrintoutsHtml(html) {
4
+ const $ = cheerio.load(html);
5
+ const printouts = [];
6
+ const seen = new Set();
7
+ $("a[href]").each((_, anchor) => {
8
+ const href = $(anchor).attr("href") ?? "";
9
+ const match = /^(?:https?:\/\/[^/]+)?(\/(?:![^/]+\/)?printouts\/(\d+)\.pdf)(?:[?#].*)?$/.exec(href);
10
+ if (!match || seen.has(match[2]))
11
+ return;
12
+ seen.add(match[2]);
13
+ printouts.push({
14
+ id: match[2],
15
+ title: $(anchor).text().replace(/\s+/g, " ").trim() || `Printout ${match[2]}`,
16
+ path: match[1],
17
+ });
18
+ });
19
+ return printouts;
20
+ }
@@ -1,2 +1,8 @@
1
1
  import type { ScheduleLesson } from "../types.js";
2
2
  export declare function parseScheduleHtml(html: string): ScheduleLesson[];
3
+ /**
4
+ * Lessons from the timetable API (`/api/v1/schedules/timetable`): one entry
5
+ * per date a lesson takes place, with every teacher and room. Field values
6
+ * match the schedule page's ("BI_ Biologia", "Lastname Firstname").
7
+ */
8
+ export declare function parseTimetableJson(payload: unknown): ScheduleLesson[];
@@ -81,3 +81,40 @@ function firstTeacher(groups) {
81
81
  function firstSubjectToken(subject) {
82
82
  return subject.trim().split(/\s+/, 1)[0] ?? "";
83
83
  }
84
+ /**
85
+ * Lessons from the timetable API (`/api/v1/schedules/timetable`): one entry
86
+ * per date a lesson takes place, with every teacher and room. Field values
87
+ * match the schedule page's ("BI_ Biologia", "Lastname Firstname").
88
+ */
89
+ export function parseTimetableJson(payload) {
90
+ if (!Array.isArray(payload))
91
+ return [];
92
+ const lessons = [];
93
+ for (const lesson of payload) {
94
+ const start = (lesson.startAt ?? "").slice(0, 5);
95
+ const end = (lesson.endsAt ?? "").slice(0, 5);
96
+ for (const rawDate of lesson.dates ?? []) {
97
+ const date = rawDate.slice(0, 10);
98
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
99
+ continue;
100
+ for (const module of lesson.modules ?? []) {
101
+ const subject = (module.caption ?? "").trim();
102
+ const teachers = module.teachers ?? [];
103
+ const rooms = (module.rooms ?? []).map((room) => (room.abbreviation ?? room.caption ?? "").trim()).filter(Boolean);
104
+ lessons.push({
105
+ date,
106
+ dayOfWeek: new Date(`${date}T12:00:00Z`).getUTCDay(),
107
+ start,
108
+ end,
109
+ subject,
110
+ subjectCode: firstSubjectToken(subject),
111
+ teacher: teachers.map((t) => [t.lastname, t.firstname].filter(Boolean).join(" ")).join(", "),
112
+ teacherCode: teachers.map((t) => t.abbreviation ?? "").filter(Boolean).join(", "),
113
+ groupId: Number(module.id) || 0,
114
+ room: rooms.length ? rooms.join(", ") : null,
115
+ });
116
+ }
117
+ }
118
+ }
119
+ return lessons.sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start));
120
+ }
@@ -1,8 +1,5 @@
1
- export interface StudentInfo {
2
- studentNumber: string;
3
- name: string;
4
- href: string;
5
- }
1
+ import type { StudentInfo } from "../types.js";
2
+ export type { StudentInfo };
6
3
  export declare function studentNumberFromHref(href: string): string | null;
7
4
  export declare function parseStudentFromUrl(url: string): StudentInfo | null;
8
5
  /**
@@ -46,7 +46,12 @@ function roleType(rec) {
46
46
  return rec.Type ?? rec.type ?? rec.RoleType ?? rec.roleType;
47
47
  }
48
48
  function roleName(rec, fallback) {
49
- return String(rec.Name ?? rec.name ?? rec.Caption ?? rec.caption ?? fallback);
49
+ // First non-empty name; an empty Name must not drop the student.
50
+ for (const value of [rec.Name, rec.name, rec.Caption, rec.caption]) {
51
+ if (typeof value === "string" && value.trim())
52
+ return value.trim();
53
+ }
54
+ return fallback;
50
55
  }
51
56
  function collectStudent(students, studentNumber, name) {
52
57
  if (!students.has(studentNumber)) {
@@ -100,6 +105,8 @@ export function parseStudentsFromAccountsRoles(data) {
100
105
  export function parseStudentsFromHome(html, pageUrl) {
101
106
  const $ = cheerio.load(html);
102
107
  const named = new Map();
108
+ // Names from links deeper than a student's root: used only when a student has no root link.
109
+ const deepNamed = new Map();
103
110
  const fromHref = new Map();
104
111
  $("a[href]").each((_, anchor) => {
105
112
  const href = $(anchor).attr("href") ?? "";
@@ -118,20 +125,28 @@ export function parseStudentsFromHome(html, pageUrl) {
118
125
  if (!text) {
119
126
  return;
120
127
  }
121
- const lower = text.toLowerCase();
122
- if (NAV_KEYWORDS.some((kw) => lower.includes(kw))) {
128
+ // Keywords match whole words only, so a name like "Ella Newsome" isn't
129
+ // mistaken for "news".
130
+ const words = text.toLowerCase().split(/[\s()]+/);
131
+ if (NAV_KEYWORDS.some((kw) => words.includes(kw))) {
123
132
  return;
124
133
  }
125
- if (!named.has(studentNumber)) {
126
- named.set(studentNumber, {
134
+ // Menu links go deeper than the student's root (/!123/messages) and their
135
+ // text is usually a section name, so a root link's text wins.
136
+ const target = /\/!\d+\/[^/?#]+/.test(href) ? deepNamed : named;
137
+ if (!target.has(studentNumber)) {
138
+ target.set(studentNumber, {
127
139
  studentNumber,
128
140
  name: text,
129
141
  href,
130
142
  });
131
143
  }
132
144
  });
133
- if (named.size > 0) {
134
- return [...named.values()];
145
+ if (named.size > 0 || deepNamed.size > 0) {
146
+ // Every named student, in page order; none dropped for lacking a root link.
147
+ return [...fromHref.keys()]
148
+ .map((number) => named.get(number) ?? deepNamed.get(number))
149
+ .filter((student) => Boolean(student));
135
150
  }
136
151
  if (fromHref.size > 0) {
137
152
  return [...fromHref.values()];
package/dist/session.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type RequestInit, type Response } from "undici";
2
2
  export declare class AuthenticationError extends Error {
3
+ constructor(message: string);
3
4
  }
4
5
  export declare class MfaRequiredError extends Error {
5
6
  formkey: string;
@@ -11,24 +12,75 @@ export declare class APIError extends Error {
11
12
  }
12
13
  export declare class WilmaSession {
13
14
  private baseUrl;
14
- private cookieJar;
15
- private loggedIn;
16
- private username?;
17
- private password?;
15
+ private auth;
18
16
  private studentNumber?;
19
17
  private debug;
20
18
  constructor(baseUrl: string, opts?: {
21
19
  studentNumber?: string | null;
22
20
  debug?: boolean;
23
21
  });
22
+ /** The login's cookies, so another process can resume the session (see resumeState). */
23
+ exportState(): string;
24
+ /**
25
+ * Continue a session saved by exportState() instead of logging in. If Wilma
26
+ * has ended it meanwhile, the first request notices (401/403 or the login
27
+ * page) and logs in again with these credentials. Returns false when the
28
+ * state holds no session to resume.
29
+ */
30
+ resumeState(state: string, username: string, password: string): boolean;
31
+ /** Called after every successful login (first or again). */
32
+ onLogin(callback: (() => void) | undefined): void;
33
+ /** Used to answer the two-step verification challenge when the session has to log in again. */
34
+ setMfaCallback(onMfa?: (formkey: string) => Promise<string>): void;
35
+ /**
36
+ * A session for another student on the same account. It shares this
37
+ * session's login (cookies and credentials), so no new login is needed —
38
+ * Wilma picks the student from the "/!<number>/" path prefix.
39
+ */
40
+ forStudent(studentNumber: string | null): WilmaSession;
24
41
  get urlPrefix(): string | null;
25
42
  getPrefixedPath(path: string): string;
26
43
  login(username: string, password: string): Promise<void>;
27
44
  submitMfaCode(formkey: string, otpCode: string): Promise<void>;
28
45
  private getLoginFormFields;
29
- request(path: string, init?: RequestInit): Promise<Response>;
30
- get(path: string, init?: RequestInit): Promise<Response>;
46
+ /**
47
+ * An authenticated request to this Wilma. Follows redirects within Wilma
48
+ * only; a redirect to another site is an error unless `externalRedirect`
49
+ * is "return" (then the 3xx response is handed back, e.g. so a download can
50
+ * continue without Wilma credentials).
51
+ */
52
+ request(path: string, init?: RequestInit, opts?: {
53
+ externalRedirect?: "error" | "return"; /** Idle timeout, e.g. longer for downloads. */
54
+ timeoutMs?: number;
55
+ }): Promise<Response>;
56
+ /** A redirect to this Wilma's login page: the session is gone. */
57
+ private isLoginRedirect;
58
+ /**
59
+ * Is this login still live? A cancelled session gets 401/403 everywhere;
60
+ * a live one answers the account roles API (or 404 on Wilmas without it).
61
+ * Lets a 403 on one item be told apart from a cancelled session without
62
+ * logging in again (which would log the parent out of their own browser).
63
+ */
64
+ private sessionAlive;
65
+ /**
66
+ * Answer a two-step verification challenge. If Wilma rejects the code (most
67
+ * likely because it was already used — logins less than 30 seconds apart
68
+ * share one), ask once more for the same challenge; the callback then
69
+ * waits for a fresh code.
70
+ */
71
+ answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
72
+ /** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
73
+ private relogin;
74
+ get(path: string, init?: RequestInit, opts?: {
75
+ externalRedirect?: "error" | "return";
76
+ timeoutMs?: number;
77
+ }): Promise<Response>;
31
78
  post(path: string, init?: RequestInit): Promise<Response>;
32
79
  private getLoginToken;
80
+ /**
81
+ * One request to this Wilma, following redirects within Wilma hop by hop
82
+ * (cookies are stored per hop, and only for Wilma). A redirect to another
83
+ * origin is returned as-is: Wilma's cookies never travel there.
84
+ */
33
85
  private rawRequest;
34
86
  }