@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,263 @@
1
+ /*
2
+ * Fetching files that school bulletins link to on other sites.
3
+ *
4
+ * Bulletin links are written by school staff (or by whoever controls a staff
5
+ * account), and this code may also run on a server. So external fetches:
6
+ * - never carry Wilma credentials (an isolated, empty cookie jar per fetch),
7
+ * - only go to public internet addresses on ports 80/443: private, loopback,
8
+ * link-local and other special ranges are refused at every redirect hop and
9
+ * again at connect time, after DNS resolution (which also stops DNS
10
+ * rebinding),
11
+ * - fail when the site stops sending for a while (an idle timeout, so a big
12
+ * file on a slow connection still finishes).
13
+ */
14
+ import { lookup as dnsLookup } from "node:dns";
15
+ import { BlockList, isIP } from "node:net";
16
+ import { CookieJar } from "tough-cookie";
17
+ import { Agent, fetch, Headers } from "undici";
18
+ import { APIError } from "./session.js";
19
+ import { NetworkError } from "./network-error.js";
20
+ import { IdleTimer, asNetworkError, watchBody } from "./timeouts.js";
21
+ /** How long a download may stall (before answering, or mid-file) before it fails. */
22
+ export const DOWNLOAD_TIMEOUT_MS = 60_000;
23
+ const MAX_REDIRECTS = 10;
24
+ const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
25
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
26
+ const blocked = new BlockList();
27
+ for (const [network, prefix] of [
28
+ ["0.0.0.0", 8], // "this network"
29
+ ["10.0.0.0", 8], // private
30
+ ["100.64.0.0", 10], // carrier-grade NAT
31
+ ["127.0.0.0", 8], // loopback
32
+ ["169.254.0.0", 16], // link-local, cloud metadata services
33
+ ["172.16.0.0", 12], // private
34
+ ["192.0.0.0", 24], // IETF protocol assignments
35
+ ["192.168.0.0", 16], // private
36
+ ["198.18.0.0", 15], // benchmarking
37
+ ["224.0.0.0", 4], // multicast
38
+ ["240.0.0.0", 4], // reserved, broadcast
39
+ ]) {
40
+ blocked.addSubnet(network, prefix, "ipv4");
41
+ }
42
+ for (const [network, prefix] of [
43
+ ["::", 128], // unspecified
44
+ ["::1", 128], // loopback
45
+ ["fc00::", 7], // unique local
46
+ ["fe80::", 10], // link-local
47
+ ["ff00::", 8], // multicast
48
+ ]) {
49
+ blocked.addSubnet(network, prefix, "ipv6");
50
+ }
51
+ /** Tests serve "external" files from 127.0.0.1; nothing else should set this. */
52
+ function privateNetworkAllowed() {
53
+ return process.env.WILMAI_ALLOW_PRIVATE_NETWORK === "1";
54
+ }
55
+ export function isBlockedAddress(address) {
56
+ const family = isIP(address);
57
+ if (family === 4)
58
+ return blocked.check(address, "ipv4");
59
+ if (family === 6) {
60
+ const words = ipv6Words(address);
61
+ if (!words)
62
+ return true;
63
+ const zero = (from, to) => words.slice(from, to).every((w) => w === 0);
64
+ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
65
+ // IPv6 forms that carry an IPv4 address: judge them by that address.
66
+ if (zero(0, 5) && words[5] === 0xffff)
67
+ return blocked.check(v4(words[6], words[7]), "ipv4"); // ::ffff:a.b.c.d
68
+ if (zero(0, 4) && words[4] === 0xffff && words[5] === 0)
69
+ return blocked.check(v4(words[6], words[7]), "ipv4"); // ::ffff:0:a.b.c.d
70
+ if (zero(0, 6))
71
+ return blocked.check(v4(words[6], words[7]), "ipv4"); // ::a.b.c.d (and :: / ::1)
72
+ if (words[0] === 0x64 && words[1] === 0xff9b && zero(2, 6))
73
+ return blocked.check(v4(words[6], words[7]), "ipv4"); // NAT64
74
+ if (words[0] === 0x2002)
75
+ return blocked.check(v4(words[1], words[2]), "ipv4"); // 6to4
76
+ // Local-use NAT64 (64:ff9b:1::/48) and Teredo (2001::/32) map to addresses we can't check.
77
+ if ((words[0] === 0x64 && words[1] === 0xff9b && words[2] === 1) || (words[0] === 0x2001 && words[1] === 0))
78
+ return true;
79
+ return blocked.check(address, "ipv6");
80
+ }
81
+ return false;
82
+ }
83
+ /** The eight 16-bit words of an IPv6 address (null if it can't be read). */
84
+ function ipv6Words(address) {
85
+ let text = address.toLowerCase().split("%")[0];
86
+ const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
87
+ if (dotted) {
88
+ const [a, b, c, d] = dotted.slice(1).map(Number);
89
+ text = text.slice(0, dotted.index) + `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
90
+ }
91
+ const halves = text.split("::");
92
+ if (halves.length > 2)
93
+ return null;
94
+ const head = halves[0] ? halves[0].split(":") : [];
95
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
96
+ const missing = 8 - head.length - tail.length;
97
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
98
+ return null;
99
+ const words = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...tail].map((w) => parseInt(w, 16));
100
+ return words.every((w) => Number.isInteger(w) && w >= 0 && w <= 0xffff) ? words : null;
101
+ }
102
+ /** Refuse URLs that point at private or special addresses, or at unusual ports. */
103
+ export function assertPublicUrl(url) {
104
+ if (privateNetworkAllowed())
105
+ return;
106
+ const refuse = () => {
107
+ throw new NetworkError(`WilmAI won't fetch ${url.protocol}//${url.host}: links must point to a public website`, {
108
+ code: "BLOCKED_ADDRESS",
109
+ origin: url.origin,
110
+ });
111
+ };
112
+ if (url.protocol !== "https:" && url.protocol !== "http:")
113
+ refuse();
114
+ if (url.port && url.port !== "80" && url.port !== "443")
115
+ refuse();
116
+ const host = url.hostname.replace(/^\[|\]$/g, "");
117
+ if (host === "localhost" || host.endsWith(".localhost"))
118
+ refuse();
119
+ // IP literals skip DNS, so check them here (the URL parser has already turned
120
+ // forms like "2130706433" into dotted notation).
121
+ if (isIP(host) && isBlockedAddress(host))
122
+ refuse();
123
+ }
124
+ // Checks the addresses a host name resolves to, at connect time.
125
+ function guardedLookup(hostname, options, callback) {
126
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
127
+ if (err)
128
+ return callback(err);
129
+ const list = addresses;
130
+ if (!privateNetworkAllowed() && list.some((entry) => isBlockedAddress(entry.address))) {
131
+ const blockedErr = new Error(`${hostname} resolves to a private address`);
132
+ blockedErr.code = "BLOCKED_ADDRESS";
133
+ return callback(blockedErr);
134
+ }
135
+ if (options?.all)
136
+ return callback(null, list);
137
+ return callback(null, list[0]?.address, list[0]?.family);
138
+ });
139
+ }
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
+ // Some document hosts serve an HTML viewer page for a sharing URL but return
146
+ // the file itself when a conventional download parameter is present. These are
147
+ // generic retry variants, not provider detection: the original URL is always
148
+ // attempted first, and a variant is tried only after an HTML answer.
149
+ const DOWNLOAD_PARAM_VARIANTS = [
150
+ ["download", "1"],
151
+ ["dl", "1"],
152
+ ];
153
+ function buildDownloadCandidates(rawUrl) {
154
+ const original = new URL(rawUrl);
155
+ const candidates = [original];
156
+ for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
157
+ if (!original.searchParams.has(param)) {
158
+ const variant = new URL(original.href);
159
+ variant.searchParams.set(param, value);
160
+ candidates.push(variant);
161
+ }
162
+ }
163
+ return candidates;
164
+ }
165
+ /**
166
+ * Fetch an external URL the way a signed-out browser would: an isolated
167
+ * in-memory cookie jar, redirects followed across hosts, and no Wilma
168
+ * credentials anywhere. Returns null when every candidate answered with an
169
+ * HTML page instead of a file; throws for HTTP errors and refused addresses.
170
+ */
171
+ export async function fetchExternalFile(rawUrl) {
172
+ let failedStatus = null;
173
+ for (const candidate of buildDownloadCandidates(rawUrl)) {
174
+ const response = await fetchFollowingRedirects(candidate);
175
+ if (!response) {
176
+ continue;
177
+ }
178
+ if (!response.ok) {
179
+ failedStatus ??= response.status;
180
+ await response.body?.cancel();
181
+ continue;
182
+ }
183
+ if (isHtmlResponse(response)) {
184
+ await response.body?.cancel();
185
+ continue;
186
+ }
187
+ return response;
188
+ }
189
+ if (failedStatus !== null) {
190
+ throw new APIError(`The linked site answered HTTP ${failedStatus}`, failedStatus);
191
+ }
192
+ return null;
193
+ }
194
+ async function fetchFollowingRedirects(initialUrl) {
195
+ const cookieJar = new CookieJar();
196
+ const idle = new IdleTimer(DOWNLOAD_TIMEOUT_MS);
197
+ let currentUrl = initialUrl;
198
+ for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
199
+ try {
200
+ assertPublicUrl(currentUrl);
201
+ }
202
+ catch (err) {
203
+ idle.stop();
204
+ throw err;
205
+ }
206
+ const headers = new Headers({
207
+ "User-Agent": EXTERNAL_USER_AGENT,
208
+ "Accept": "*/*",
209
+ });
210
+ const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
211
+ if (cookieHeader) {
212
+ headers.set("Cookie", cookieHeader);
213
+ }
214
+ let response;
215
+ try {
216
+ idle.touch();
217
+ response = await fetch(currentUrl, {
218
+ headers,
219
+ redirect: "manual",
220
+ signal: idle.signal,
221
+ dispatcher: privateNetworkAllowed() ? undefined : guardedAgent,
222
+ });
223
+ }
224
+ catch (err) {
225
+ idle.stop();
226
+ const cause = err.cause;
227
+ if (cause?.code === "BLOCKED_ADDRESS") {
228
+ throw new NetworkError(`WilmAI won't fetch ${currentUrl.host}: it points to a private address`, {
229
+ code: "BLOCKED_ADDRESS",
230
+ origin: currentUrl.origin,
231
+ cause: err,
232
+ });
233
+ }
234
+ throw asNetworkError(err, currentUrl.origin);
235
+ }
236
+ const setCookies = response.headers.getSetCookie?.() ?? [];
237
+ for (const cookie of setCookies) {
238
+ cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
239
+ }
240
+ if (!setCookies.length) {
241
+ const cookie = response.headers.get("set-cookie");
242
+ if (cookie)
243
+ cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
244
+ }
245
+ if (response.status < 300 || response.status >= 400) {
246
+ return watchBody(response, idle, currentUrl.origin);
247
+ }
248
+ const location = response.headers.get("location");
249
+ await response.body?.cancel();
250
+ if (!location) {
251
+ idle.stop();
252
+ return null;
253
+ }
254
+ const nextUrl = new URL(location, currentUrl);
255
+ if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
256
+ idle.stop();
257
+ return null;
258
+ }
259
+ currentUrl = nextUrl;
260
+ }
261
+ idle.stop();
262
+ throw new Error("External resource exceeded the redirect limit");
263
+ }
@@ -0,0 +1,19 @@
1
+ export interface FinnishParts {
2
+ year: number;
3
+ month: number;
4
+ day: number;
5
+ hour: number;
6
+ minute: number;
7
+ second: number;
8
+ }
9
+ /** The Finnish wall-clock time of an instant. */
10
+ export declare function finnishParts(date: Date): FinnishParts;
11
+ /** YYYY-MM-DD of an instant in Finnish time. */
12
+ export declare function finnishDateString(date?: Date): string;
13
+ /**
14
+ * "2026-10-02T13:37:00+03:00": an instant as Finnish wall-clock time with its
15
+ * offset — unambiguous, and readable without converting from UTC.
16
+ */
17
+ export declare function finnishIsoString(date: Date): string;
18
+ /** The instant of a Finnish wall-clock time (handles summer time). */
19
+ export declare function finnishTime(year: number, month: number, day: number, hour?: number, minute?: number, second?: number): Date;
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Wilma shows times in Finnish local time. The code may run anywhere — a
3
+ * parent's laptop, or a server in UTC — so conversions go through
4
+ * Europe/Helsinki explicitly instead of the machine's time zone.
5
+ */
6
+ const TIME_ZONE = "Europe/Helsinki";
7
+ const formatter = new Intl.DateTimeFormat("en-GB", {
8
+ timeZone: TIME_ZONE,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ second: "2-digit",
15
+ hourCycle: "h23",
16
+ });
17
+ /** The Finnish wall-clock time of an instant. */
18
+ export function finnishParts(date) {
19
+ const parts = {};
20
+ for (const part of formatter.formatToParts(date)) {
21
+ if (part.type !== "literal")
22
+ parts[part.type] = Number(part.value);
23
+ }
24
+ return {
25
+ year: parts.year,
26
+ month: parts.month,
27
+ day: parts.day,
28
+ hour: parts.hour,
29
+ minute: parts.minute,
30
+ second: parts.second,
31
+ };
32
+ }
33
+ /** YYYY-MM-DD of an instant in Finnish time. */
34
+ export function finnishDateString(date = new Date()) {
35
+ const p = finnishParts(date);
36
+ return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
37
+ }
38
+ /**
39
+ * "2026-10-02T13:37:00+03:00": an instant as Finnish wall-clock time with its
40
+ * offset — unambiguous, and readable without converting from UTC.
41
+ */
42
+ export function finnishIsoString(date) {
43
+ const p = finnishParts(date);
44
+ const pad = (n) => String(n).padStart(2, "0");
45
+ const offset = Math.round(offsetMs(date.getTime()) / 60000);
46
+ const sign = offset < 0 ? "-" : "+";
47
+ const abs = Math.abs(offset);
48
+ return (`${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}` +
49
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`);
50
+ }
51
+ function offsetMs(instant) {
52
+ const p = finnishParts(new Date(instant));
53
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(instant / 1000) * 1000;
54
+ }
55
+ /** The instant of a Finnish wall-clock time (handles summer time). */
56
+ export function finnishTime(year, month, day, hour = 0, minute = 0, second = 0) {
57
+ const asUtc = Date.UTC(year, month - 1, day, hour, minute, second);
58
+ let instant = asUtc - offsetMs(asUtc);
59
+ // Near a daylight-saving switch the offset at the guess can differ; recheck once.
60
+ const corrected = asUtc - offsetMs(instant);
61
+ if (corrected !== instant)
62
+ instant = corrected;
63
+ return new Date(instant);
64
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
- export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
1
+ export type { Exam, ExamGrade, GradebookEntry, HomeworkItem, LessonNote, LessonNoteSummary, Message, MessageReply, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, Printout, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
2
2
  export { WilmaClient } from "./client.js";
3
3
  export type { MfaCallback } from "./client.js";
4
4
  export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from "./session.js";
5
5
  export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
6
6
  export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
7
7
  export { parseWilmaTimestamp } from "./parsers/dates.js";
8
+ export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
8
9
  export { parseStudentsFromHome } from "./parsers/students.js";
10
+ export { htmlToText } from "./parsers/html-text.js";
11
+ export { addExamTimes } from "./parsers/exams.js";
package/dist/index.js CHANGED
@@ -3,4 +3,7 @@ export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from ".
3
3
  export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
4
4
  export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
5
5
  export { parseWilmaTimestamp } from "./parsers/dates.js";
6
+ export { finnishDateString, finnishIsoString, finnishParts, finnishTime } from "./finnish-time.js";
6
7
  export { parseStudentsFromHome } from "./parsers/students.js";
8
+ export { htmlToText } from "./parsers/html-text.js";
9
+ export { addExamTimes } from "./parsers/exams.js";
@@ -1,4 +1,4 @@
1
- import type { LessonNote } from "../types.js";
1
+ import type { LessonNote, LessonNoteSummary } from "../types.js";
2
2
  /**
3
3
  * Parse Wilma's /attendance/view HTML page into structured LessonNote objects.
4
4
  *
@@ -15,4 +15,6 @@ import type { LessonNote } from "../types.js";
15
15
  * Title attribute on event cells: "TypeLabel /TeacherName" or
16
16
  * "SubjectCode; TypeLabel /TeacherName".
17
17
  */
18
- export declare function parseAttendanceHtml(html: string, date: string): LessonNote[];
18
+ export declare function parseAttendanceHtml(html: string, date?: string): LessonNote[];
19
+ /** Count lesson notes by kind (absences, lateness, feedback…), most common first. */
20
+ export declare function summarizeLessonNotes(notes: LessonNote[], from: string | null, to: string | null): LessonNoteSummary;
@@ -18,7 +18,17 @@ import * as cheerio from "cheerio";
18
18
  export function parseAttendanceHtml(html, date) {
19
19
  const $ = cheerio.load(html);
20
20
  const notes = [];
21
- const targetFinnish = dateToFinnish(date);
21
+ // One day, or every day the page shows (the page covers a chosen period).
22
+ const targetFinnish = date ? dateToFinnish(date) : null;
23
+ // The legend table: type class (at-tpN) -> label.
24
+ const legend = new Map();
25
+ $("tr").each((_, row) => {
26
+ const cells = $(row).find("td");
27
+ const tp = (($(cells[0]).attr("class") ?? "").match(/\bat-tp\d+\b/) ?? [])[0];
28
+ const label = $(cells[cells.length - 1]).text().trim();
29
+ if (tp && cells.length === 2 && label && !legend.has(tp))
30
+ legend.set(tp, label);
31
+ });
22
32
  $("table").each((_, table) => {
23
33
  const $table = $(table);
24
34
  // Build a flat array indexed by grid-column whose value is the hour.
@@ -42,7 +52,10 @@ export function parseAttendanceHtml(html, date) {
42
52
  if (cells.length < 3)
43
53
  return; // need at least weekday, date, and one slot
44
54
  const rowDate = $(cells[1]).text().trim();
45
- if (!rowDate || rowDate !== targetFinnish)
55
+ if (!rowDate || (targetFinnish && rowDate !== targetFinnish))
56
+ return;
57
+ const rowIso = finnishToIso(rowDate);
58
+ if (!rowIso)
46
59
  return;
47
60
  // Walk event-grid cells. Indices 0..1 are weekday + date (outside grid).
48
61
  let gridCol = 0;
@@ -56,24 +69,31 @@ export function parseAttendanceHtml(html, date) {
56
69
  // Title formats observed:
57
70
  // "TypeLabel /TeacherFullName"
58
71
  // "SubjectCode; TypeLabel /TeacherFullName"
59
- // "SubjectCode; TypeLabel; ExtraNote /TeacherFullName"
72
+ // "SubjectCode; TypeLabel; teacher's note /TeacherFullName"
73
+ // "TypeLabel; teacher's note /TeacherFullName" (no lesson)
74
+ // The page's legend names each type class, which tells the label
75
+ // apart from a subject code or a note.
60
76
  let subject = "";
61
77
  let typeLabel = "";
78
+ let note = null;
62
79
  let teacher = cellText;
63
80
  if (title) {
64
81
  let rest = title;
65
- const semiIdx = rest.indexOf(";");
66
- if (semiIdx > 0) {
67
- subject = rest.slice(0, semiIdx).trim();
68
- rest = rest.slice(semiIdx + 1).trim();
69
- }
70
82
  const slashIdx = rest.lastIndexOf(" /");
71
83
  if (slashIdx > 0) {
72
- const afterSlash = rest.slice(slashIdx + 2);
73
- const spaceAfter = afterSlash.startsWith(" ") ? 1 : 0;
74
- typeLabel = rest.slice(0, slashIdx).trim();
75
- teacher = rest.slice(slashIdx + 2 + spaceAfter).trim();
84
+ teacher = rest.slice(slashIdx + 2).trim();
85
+ rest = rest.slice(0, slashIdx).trim();
76
86
  }
87
+ const parts = rest.split(";").map((part) => part.trim());
88
+ const known = legend.get(tpClass);
89
+ let labelAt = known ? parts.indexOf(known) : -1;
90
+ if (labelAt < 0) {
91
+ // No legend: a leading course code (no spaces, e.g. "MA_71") is the subject.
92
+ labelAt = parts.length > 1 && !/\s/.test(parts[0]) ? 1 : 0;
93
+ }
94
+ subject = parts.slice(0, labelAt).join("; ");
95
+ typeLabel = parts[labelAt] ?? "";
96
+ note = parts.slice(labelAt + 1).join("; ") || null;
77
97
  }
78
98
  // Map cell's grid range to start/end via the thead-derived map.
79
99
  // start = hour at the cell's first grid column.
@@ -89,13 +109,14 @@ export function parseAttendanceHtml(html, date) {
89
109
  end = `${pad(endHour)}:45`;
90
110
  }
91
111
  notes.push({
92
- date,
112
+ date: rowIso,
93
113
  start,
94
114
  end,
95
115
  subject,
96
116
  typeLabel: typeLabel || tpClass.replace("at-tp", "Type "),
97
117
  typeClass: tpClass,
98
118
  teacher,
119
+ note,
99
120
  });
100
121
  }
101
122
  gridCol += colspan;
@@ -104,6 +125,22 @@ export function parseAttendanceHtml(html, date) {
104
125
  });
105
126
  return notes;
106
127
  }
128
+ /** Count lesson notes by kind (absences, lateness, feedback…), most common first. */
129
+ export function summarizeLessonNotes(notes, from, to) {
130
+ const counts = new Map();
131
+ for (const note of notes)
132
+ counts.set(note.typeLabel, (counts.get(note.typeLabel) ?? 0) + 1);
133
+ return {
134
+ from,
135
+ to,
136
+ total: notes.length,
137
+ byType: [...counts].map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)),
138
+ };
139
+ }
140
+ function finnishToIso(value) {
141
+ const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
142
+ return match ? `${match[3]}-${match[2].padStart(2, "0")}-${match[1].padStart(2, "0")}` : null;
143
+ }
107
144
  function pad(n) {
108
145
  return String(n).padStart(2, "0");
109
146
  }
@@ -1,3 +1,4 @@
1
+ import { finnishParts, finnishTime } from "../finnish-time.js";
1
2
  export function parseWilmaTimestamp(value) {
2
3
  if (value === null || value === undefined) {
3
4
  return fallbackDate();
@@ -23,7 +24,7 @@ export function parseWilmaTimestamp(value) {
23
24
  const isoLikeMatch = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/.exec(raw);
24
25
  if (isoLikeMatch) {
25
26
  const [, y, m, d, h, mi] = isoLikeMatch.map(Number);
26
- return new Date(y, m - 1, d, h, mi, 0, 0);
27
+ return finnishTime(y, m, d, h, mi);
27
28
  }
28
29
  // Try Unix timestamp as string
29
30
  if (/^\d{10,13}$/.test(raw)) {
@@ -37,7 +38,7 @@ export function parseWilmaTimestamp(value) {
37
38
  .replace("julkaistu", "")
38
39
  .trim();
39
40
  const now = new Date();
40
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
41
+ const todayParts = finnishParts(now);
41
42
  const rel = {
42
43
  "tänään": 0,
43
44
  "eilen": 1,
@@ -50,15 +51,12 @@ export function parseWilmaTimestamp(value) {
50
51
  };
51
52
  for (const [kw, daysAgo] of Object.entries(rel)) {
52
53
  if (text.includes(kw)) {
53
- const base = new Date(today);
54
- base.setDate(base.getDate() - daysAgo);
55
54
  const timeMatch = /(\d{1,2})[:.](\d{2})/.exec(text);
56
- if (timeMatch) {
57
- const h = Number(timeMatch[1]);
58
- const m = Number(timeMatch[2]);
59
- base.setHours(h, m, 0, 0);
60
- }
61
- return base;
55
+ const h = timeMatch ? Number(timeMatch[1]) : 0;
56
+ const m = timeMatch ? Number(timeMatch[2]) : 0;
57
+ // Day arithmetic in UTC on the Finnish calendar date, then back to Finnish time.
58
+ const day = new Date(Date.UTC(todayParts.year, todayParts.month - 1, todayParts.day - daysAgo));
59
+ return finnishTime(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), h, m);
62
60
  }
63
61
  }
64
62
  for (const fmt of ["dateTime", "dateOnly"]) {
@@ -66,14 +64,14 @@ export function parseWilmaTimestamp(value) {
66
64
  const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})\s+(\d{1,2}):(\d{2})$/.exec(text);
67
65
  if (match) {
68
66
  const [, d, m, y, h, mi] = match.map(Number);
69
- return new Date(y, m - 1, d, h, mi, 0, 0);
67
+ return finnishTime(y, m, d, h, mi);
70
68
  }
71
69
  }
72
70
  else {
73
71
  const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(text);
74
72
  if (match) {
75
73
  const [, d, m, y] = match.map(Number);
76
- return new Date(y, m - 1, d);
74
+ return finnishTime(y, m, d);
77
75
  }
78
76
  }
79
77
  }
@@ -82,11 +80,10 @@ export function parseWilmaTimestamp(value) {
82
80
  const [, dStr, mStr] = shortMatch;
83
81
  const day = Number(dStr);
84
82
  const month = Number(mStr);
85
- const candidate = new Date(now.getFullYear(), month - 1, day);
86
- const sixMonthsAhead = new Date(today);
87
- sixMonthsAhead.setDate(sixMonthsAhead.getDate() + 180);
88
- if (candidate > sixMonthsAhead) {
89
- candidate.setFullYear(candidate.getFullYear() - 1);
83
+ // A day and month without a year: this year, unless that is over six months ahead.
84
+ let candidate = finnishTime(todayParts.year, month, day);
85
+ if (candidate.getTime() > now.getTime() + 180 * 24 * 60 * 60 * 1000) {
86
+ candidate = finnishTime(todayParts.year - 1, month, day);
90
87
  }
91
88
  return candidate;
92
89
  }
@@ -1,2 +1,9 @@
1
- import type { Exam } from "../types.js";
1
+ import type { Exam, UpcomingExam } from "../types.js";
2
2
  export declare function parseExamsHtml(html: string): Exam[];
3
+ /**
4
+ * Add start times from the exam calendar to upcoming exams (the front page's
5
+ * exam list has dates only). An exam is matched by date and its course code
6
+ * (e.g. "MA_91" appears in the calendar entry), or by date alone when it is
7
+ * the only exam that day in both lists.
8
+ */
9
+ export declare function addExamTimes(upcoming: UpcomingExam[], calendar: Exam[]): UpcomingExam[];
@@ -27,6 +27,9 @@ export function parseExamsHtml(html) {
27
27
  const examDate = new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
28
28
  // Also store as local date string to avoid any serialization issues
29
29
  const dateString = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
30
+ // "Pe 30.10.2026 Klo 08:30": some exams have a start time.
31
+ const timeMatch = /klo\s*(\d{1,2})[:.](\d{2})/i.exec(dateText);
32
+ const time = timeMatch ? `${timeMatch[1].padStart(2, "0")}:${timeMatch[2]}` : null;
30
33
  // Compact whitespace first to normalize the text before parsing
31
34
  const subjDesc = compactText($(firstCells.get(1)).text());
32
35
  let subject = subjDesc;
@@ -66,12 +69,46 @@ export function parseExamsHtml(html) {
66
69
  description: description ? compactText(description) : null,
67
70
  teacher: teacher ? compactText(teacher) : null,
68
71
  notes: notes ? compactText(notes) : null,
72
+ time,
69
73
  fetchedAt: now,
70
74
  });
71
75
  autoId += 1;
72
76
  });
73
77
  return exams;
74
78
  }
79
+ /**
80
+ * Add start times from the exam calendar to upcoming exams (the front page's
81
+ * exam list has dates only). An exam is matched by date and its course code
82
+ * (e.g. "MA_91" appears in the calendar entry), or by date alone when it is
83
+ * the only exam that day in both lists.
84
+ */
85
+ export function addExamTimes(upcoming, calendar) {
86
+ return upcoming.map((exam) => {
87
+ const sameDay = calendar.filter((entry) => entry.dateString === exam.date);
88
+ const code = exam.subjectCode ? new RegExp(`(^|\\s)${escapeRegExp(exam.subjectCode)}(\\s|$|[.,:])`) : null;
89
+ const text = (entry) => `${entry.description ?? ""} ${entry.subject}`;
90
+ let byCode = code ? sameDay.filter((entry) => code.test(text(entry))) : [];
91
+ if (byCode.length > 1) {
92
+ // Several exams of one course that day: the exam's name tells them apart.
93
+ const nameWords = words(exam.name);
94
+ const scored = byCode.map((entry) => {
95
+ const entryWords = new Set(words(text(entry)));
96
+ return { entry, score: nameWords.filter((word) => entryWords.has(word)).length };
97
+ });
98
+ const best = Math.max(...scored.map((s) => s.score));
99
+ byCode = best > 0 ? scored.filter((s) => s.score === best).map((s) => s.entry) : byCode;
100
+ }
101
+ const onlyOne = sameDay.length === 1 && upcoming.filter((other) => other.date === exam.date).length === 1;
102
+ const match = byCode.length === 1 ? byCode[0] : onlyOne ? sameDay[0] : null;
103
+ return { ...exam, time: match?.time ?? null };
104
+ });
105
+ }
106
+ function words(value) {
107
+ return value.toLowerCase().match(/[\p{L}\d]{3,}/gu) ?? [];
108
+ }
109
+ function escapeRegExp(value) {
110
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
111
+ }
75
112
  function compactText(value) {
76
113
  return value.replace(/\s+/g, " ").trim();
77
114
  }
@@ -0,0 +1,8 @@
1
+ import type { GradebookEntry } from "../types.js";
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 declare function parseGradebookHtml(html: string): GradebookEntry[];