@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,82 @@
1
+ /*
2
+ * Idle timeouts for requests and downloads. A request fails when the server
3
+ * stops answering for a while — before the response, or while the body is
4
+ * read — not after a fixed total, so a large attachment on a slow connection
5
+ * still finishes while a stalled one doesn't hang forever.
6
+ */
7
+ import { Response } from "undici";
8
+ import { NetworkError, wrapNetworkError } from "./network-error.js";
9
+ /** An abort signal that fires after `ms` without progress. */
10
+ export class IdleTimer {
11
+ ms;
12
+ controller = new AbortController();
13
+ timer;
14
+ constructor(ms) {
15
+ this.ms = ms;
16
+ this.touch();
17
+ }
18
+ get signal() {
19
+ return this.controller.signal;
20
+ }
21
+ /** Progress: start the wait over. */
22
+ touch() {
23
+ clearTimeout(this.timer);
24
+ this.timer = setTimeout(() => this.controller.abort(new DOMException("The server stopped answering", "TimeoutError")), this.ms);
25
+ // A response nobody reads must not keep the process alive.
26
+ this.timer.unref?.();
27
+ }
28
+ stop() {
29
+ clearTimeout(this.timer);
30
+ }
31
+ }
32
+ /** A timeout or broken connection as a NetworkError; anything else unchanged. */
33
+ export function asNetworkError(err, origin) {
34
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
35
+ return new NetworkError(`${origin} didn't answer in time`, { code: "TIMEOUT", origin, cause: err });
36
+ }
37
+ return wrapNetworkError(err, origin);
38
+ }
39
+ // Statuses whose responses can't carry a body.
40
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
41
+ /**
42
+ * The same response, with its body read under the idle timer: each chunk
43
+ * restarts the wait, and a stall or broken connection while reading surfaces
44
+ * as a NetworkError, like one before the response.
45
+ */
46
+ export function watchBody(response, idle, origin) {
47
+ if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
48
+ idle.stop();
49
+ return response;
50
+ }
51
+ const reader = response.body.getReader();
52
+ const body = new ReadableStream({
53
+ async pull(controller) {
54
+ try {
55
+ const { done, value } = await reader.read();
56
+ if (done) {
57
+ idle.stop();
58
+ controller.close();
59
+ return;
60
+ }
61
+ idle.touch();
62
+ controller.enqueue(value);
63
+ }
64
+ catch (err) {
65
+ idle.stop();
66
+ controller.error(asNetworkError(err, origin));
67
+ }
68
+ },
69
+ cancel(reason) {
70
+ idle.stop();
71
+ return reader.cancel(reason);
72
+ },
73
+ });
74
+ const watched = new Response(body, {
75
+ status: response.status,
76
+ statusText: response.statusText,
77
+ headers: response.headers,
78
+ });
79
+ // Parsers resolve links against the final address.
80
+ Object.defineProperty(watched, "url", { value: response.url });
81
+ return watched;
82
+ }
package/dist/types.d.ts CHANGED
@@ -9,9 +9,23 @@ export interface Message {
9
9
  senderName?: string | null;
10
10
  sendersJson?: Record<string, unknown> | null;
11
11
  status?: number | null;
12
+ /** In lists: Wilma hasn't seen it opened yet. */
13
+ unread?: boolean;
14
+ /** In lists: how many replies the thread has (read them with the message). */
15
+ replyCount?: number;
12
16
  content?: string | null;
17
+ /** Message detail: who it went to (Wilma may hide the list). */
18
+ recipients?: string[] | null;
19
+ /** Message detail: the thread's replies, oldest first. */
20
+ replies?: MessageReply[];
13
21
  fetchedAt: Date;
14
22
  }
23
+ export interface MessageReply {
24
+ id: number;
25
+ sentAt: Date;
26
+ senderName: string | null;
27
+ content: string;
28
+ }
15
29
  export interface NewsItem {
16
30
  wilmaId: number;
17
31
  title: string;
@@ -20,6 +34,10 @@ export interface NewsItem {
20
34
  published?: Date | null;
21
35
  content?: string | null;
22
36
  resources?: NewsResource[];
37
+ /** A pinned bulletin (Wilma: "Pysyvät tiedotteet"), kept on the page until removed. */
38
+ pinned?: boolean;
39
+ /** Listed among older bulletins (Wilma: "Vanhat tiedotteet"); its date is on the bulletin itself. */
40
+ archived?: boolean;
23
41
  fetchedAt: Date;
24
42
  }
25
43
  export type NewsResourceAuthContext = "external" | "wilma";
@@ -39,6 +57,8 @@ export interface Exam {
39
57
  description?: string | null;
40
58
  teacher?: string | null;
41
59
  notes?: string | null;
60
+ /** Start time ("08:30") when the school gives one; only the exam calendar has it. */
61
+ time?: string | null;
42
62
  fetchedAt: Date;
43
63
  }
44
64
  export interface StudentInfo {
@@ -78,11 +98,15 @@ export interface ScheduleLesson {
78
98
  teacher: string;
79
99
  teacherCode: string;
80
100
  groupId: number;
101
+ /** Room code(s), e.g. "407"; null when Wilma gives none. */
102
+ room?: string | null;
81
103
  }
82
104
  export interface UpcomingExam {
83
105
  examId: number;
84
106
  /** YYYY-MM-DD */
85
107
  date: string;
108
+ /** Start time ("08:30") when the school gives one (from the exam calendar). */
109
+ time?: string | null;
86
110
  name: string;
87
111
  subject: string;
88
112
  subjectCode: string;
@@ -133,4 +157,36 @@ export interface LessonNote {
133
157
  typeLabel: string;
134
158
  typeClass: string;
135
159
  teacher: string;
160
+ /** The teacher's own words, when they wrote any (e.g. what was missing, or praise). */
161
+ note?: string | null;
162
+ }
163
+ /** How many lesson notes of each kind in a period (absences, lateness, feedback…). */
164
+ export interface LessonNoteSummary {
165
+ /** YYYY-MM-DD, or null for "the whole school year" as Wilma defines it. */
166
+ from: string | null;
167
+ to: string | null;
168
+ total: number;
169
+ byType: {
170
+ type: string;
171
+ count: number;
172
+ }[];
173
+ }
174
+ /** One row of the gradebook (Suoritukset): a subject, a syllabus or a course, with its sub-rows. */
175
+ export interface GradebookEntry {
176
+ name: string;
177
+ /** Course code such as "MA_81" (null for subject rows). */
178
+ code: string | null;
179
+ grade: string | null;
180
+ /** Scope as Wilma shows it (e.g. "2 vvt"), if any. */
181
+ credits: string | null;
182
+ /** YYYY-MM-DD when completed. */
183
+ date: string | null;
184
+ children: GradebookEntry[];
185
+ }
186
+ /** A printable document Wilma offers as a PDF (report cards, absence reports…). */
187
+ export interface Printout {
188
+ id: string;
189
+ title: string;
190
+ /** Path on this Wilma, e.g. "/!123/printouts/456.pdf". */
191
+ path: string;
136
192
  }
package/package.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-client",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
+ "description": "Unofficial client for Wilma, the Finnish school information system: login (with two-step verification), schedule, homework, exams, messages and news.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/aikarjal/wilmai.git",
9
+ "directory": "packages/wilma-client"
10
+ },
11
+ "homepage": "https://wilm.ai",
12
+ "bugs": "https://github.com/aikarjal/wilmai/issues",
13
+ "engines": {
14
+ "node": ">=20.18.1"
15
+ },
4
16
  "type": "module",
5
17
  "main": "dist/index.js",
6
18
  "types": "dist/index.d.ts",
@@ -12,19 +24,21 @@
12
24
  },
13
25
  "files": [
14
26
  "dist",
15
- "tenant_list.json"
27
+ "tenant_list.json",
28
+ "LICENSE",
29
+ "README.md"
16
30
  ],
17
31
  "scripts": {
18
32
  "build": "tsc -p tsconfig.json",
19
- "lint": "echo 'add lint'",
20
- "test": "pnpm run build && 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-news-resources.mjs && node test/network-error.mjs",
21
- "test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
22
- "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
33
+ "lint": "tsc -p tsconfig.json --noEmit",
34
+ "test": "pnpm run build && node test/login.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-news-resources.mjs && node test/network-error.mjs && node test/audit.mjs && node test/real-pages.mjs && node test/client-features.mjs",
35
+ "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
36
+ "fixtures:anonymize": "node scripts/anonymize-fixture.mjs"
23
37
  },
24
38
  "dependencies": {
25
39
  "cheerio": "^1.0.0-rc.12",
26
40
  "tough-cookie": "^4.1.4",
27
- "undici": "^6.19.8"
41
+ "undici": "^6.27.0"
28
42
  },
29
43
  "devDependencies": {
30
44
  "typescript": "^5.6.3",