@wilm-ai/wilma-client 1.4.1 → 1.5.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 +18 -1
- package/dist/client.js +128 -2
- package/dist/index.d.ts +1 -1
- package/dist/parsers/news.d.ts +2 -2
- package/dist/parsers/news.js +80 -6
- package/dist/parsers/schedule.d.ts +2 -0
- package/dist/parsers/schedule.js +83 -0
- package/dist/types.d.ts +9 -0
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Exam, Message, MessageFolder, NewsItem, OverviewData, WilmaProfile, StudentInfo, LessonNote } from "./types.js";
|
|
1
|
+
import type { Exam, Message, MessageFolder, NewsItem, NewsResource, OverviewData, WilmaProfile, StudentInfo, LessonNote } from "./types.js";
|
|
2
|
+
import { type Response } from "undici";
|
|
2
3
|
export type MfaCallback = (formkey: string) => Promise<string>;
|
|
3
4
|
export declare class WilmaClient {
|
|
4
5
|
private session;
|
|
@@ -12,6 +13,17 @@ export declare class WilmaClient {
|
|
|
12
13
|
news: {
|
|
13
14
|
list: () => Promise<NewsItem[]>;
|
|
14
15
|
get: (newsId: number) => Promise<NewsItem>;
|
|
16
|
+
fetchResource: (newsId: number, resourceId: string, options?: {
|
|
17
|
+
item?: NewsItem;
|
|
18
|
+
}) => Promise<{
|
|
19
|
+
resource: NewsResource;
|
|
20
|
+
response: Response;
|
|
21
|
+
status: "fetched";
|
|
22
|
+
} | {
|
|
23
|
+
resource: NewsResource;
|
|
24
|
+
response: null;
|
|
25
|
+
status: "not_a_file";
|
|
26
|
+
}>;
|
|
15
27
|
};
|
|
16
28
|
exams: {
|
|
17
29
|
list: (opts?: {
|
|
@@ -24,6 +36,11 @@ export declare class WilmaClient {
|
|
|
24
36
|
date?: string;
|
|
25
37
|
}) => Promise<LessonNote[]>;
|
|
26
38
|
};
|
|
39
|
+
schedule: {
|
|
40
|
+
list: (opts?: {
|
|
41
|
+
date?: string;
|
|
42
|
+
}) => Promise<OverviewData["schedule"]>;
|
|
43
|
+
};
|
|
27
44
|
overview: {
|
|
28
45
|
get: () => Promise<OverviewData>;
|
|
29
46
|
};
|
package/dist/client.js
CHANGED
|
@@ -5,7 +5,12 @@ import { parseNewsDetailHtml, parseNewsDetailJson, parseNewsList, parseNewsListH
|
|
|
5
5
|
import { parseExamsHtml } from "./parsers/exams.js";
|
|
6
6
|
import { parseAttendanceHtml } from "./parsers/attendance.js";
|
|
7
7
|
import { parseOverview } from "./parsers/overview.js";
|
|
8
|
+
import { parseScheduleHtml } from "./parsers/schedule.js";
|
|
8
9
|
import { parseStudentsFromHome } from "./parsers/students.js";
|
|
10
|
+
import { CookieJar } from "tough-cookie";
|
|
11
|
+
import { fetch, Headers } from "undici";
|
|
12
|
+
const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
13
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
|
|
9
14
|
export class WilmaClient {
|
|
10
15
|
session;
|
|
11
16
|
constructor(session) {
|
|
@@ -103,10 +108,31 @@ export class WilmaClient {
|
|
|
103
108
|
if (!contentType.includes("text/html")) {
|
|
104
109
|
const data = safeJson(text);
|
|
105
110
|
if (Object.keys(data).length) {
|
|
106
|
-
return parseNewsDetailJson(newsId, data);
|
|
111
|
+
return parseNewsDetailJson(newsId, data, resp.url);
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
|
-
return parseNewsDetailHtml(text, newsId);
|
|
114
|
+
return parseNewsDetailHtml(text, newsId, resp.url);
|
|
115
|
+
},
|
|
116
|
+
fetchResource: async (newsId, resourceId, options) => {
|
|
117
|
+
const item = options?.item ?? (await this.news.get(newsId));
|
|
118
|
+
const resource = item.resources?.find((candidate) => candidate.id === resourceId);
|
|
119
|
+
if (!resource) {
|
|
120
|
+
throw new Error(`News resource "${resourceId}" not found`);
|
|
121
|
+
}
|
|
122
|
+
if (resource.authContext === "wilma") {
|
|
123
|
+
const url = new URL(resource.url);
|
|
124
|
+
const response = await this.session.get(`${url.pathname}${url.search}`);
|
|
125
|
+
if (isHtmlResponse(response)) {
|
|
126
|
+
await response.body?.cancel();
|
|
127
|
+
return { resource, response: null, status: "not_a_file" };
|
|
128
|
+
}
|
|
129
|
+
return { resource, response: response, status: "fetched" };
|
|
130
|
+
}
|
|
131
|
+
const response = await fetchExternalFile(resource.url);
|
|
132
|
+
if (!response) {
|
|
133
|
+
return { resource, response: null, status: "not_a_file" };
|
|
134
|
+
}
|
|
135
|
+
return { resource, response, status: "fetched" };
|
|
110
136
|
},
|
|
111
137
|
};
|
|
112
138
|
exams = {
|
|
@@ -138,6 +164,18 @@ export class WilmaClient {
|
|
|
138
164
|
return parseAttendanceHtml(text, opts?.date ?? "");
|
|
139
165
|
},
|
|
140
166
|
};
|
|
167
|
+
schedule = {
|
|
168
|
+
list: async (opts) => {
|
|
169
|
+
if (!opts?.date) {
|
|
170
|
+
return (await this.overview.get()).schedule;
|
|
171
|
+
}
|
|
172
|
+
const params = new URLSearchParams();
|
|
173
|
+
params.set("date", isoDateToFinnish(opts.date));
|
|
174
|
+
const resp = await this.session.get(`/schedule?${params.toString()}`);
|
|
175
|
+
const text = await resp.text();
|
|
176
|
+
return parseScheduleHtml(text);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
141
179
|
overview = {
|
|
142
180
|
get: async () => {
|
|
143
181
|
const resp = await this.session.get("/overview");
|
|
@@ -146,6 +184,87 @@ export class WilmaClient {
|
|
|
146
184
|
},
|
|
147
185
|
};
|
|
148
186
|
}
|
|
187
|
+
// Some document hosts serve an HTML viewer page for a sharing URL but return
|
|
188
|
+
// the file itself when a conventional download parameter is present. These are
|
|
189
|
+
// generic retry variants, not provider detection: the original URL is always
|
|
190
|
+
// attempted first, and a variant is tried only after an HTML answer.
|
|
191
|
+
const DOWNLOAD_PARAM_VARIANTS = [
|
|
192
|
+
["download", "1"],
|
|
193
|
+
["dl", "1"],
|
|
194
|
+
];
|
|
195
|
+
function buildDownloadCandidates(rawUrl) {
|
|
196
|
+
const original = new URL(rawUrl);
|
|
197
|
+
const candidates = [original];
|
|
198
|
+
for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
|
|
199
|
+
if (!original.searchParams.has(param)) {
|
|
200
|
+
const variant = new URL(original.href);
|
|
201
|
+
variant.searchParams.set(param, value);
|
|
202
|
+
candidates.push(variant);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return candidates;
|
|
206
|
+
}
|
|
207
|
+
function isHtmlResponse(response) {
|
|
208
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
209
|
+
return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
|
|
210
|
+
}
|
|
211
|
+
// Fetch an external URL the way a signed-out browser would: an isolated
|
|
212
|
+
// in-memory cookie jar, redirects followed across hosts, and no Wilma
|
|
213
|
+
// credentials anywhere. Returns null when every candidate answered with an
|
|
214
|
+
// HTML page instead of a file.
|
|
215
|
+
async function fetchExternalFile(rawUrl) {
|
|
216
|
+
for (const candidate of buildDownloadCandidates(rawUrl)) {
|
|
217
|
+
const response = await fetchFollowingRedirects(candidate);
|
|
218
|
+
if (!response) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (response.ok && isHtmlResponse(response)) {
|
|
222
|
+
await response.body?.cancel();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
return response;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
async function fetchFollowingRedirects(initialUrl) {
|
|
230
|
+
const cookieJar = new CookieJar();
|
|
231
|
+
let currentUrl = initialUrl;
|
|
232
|
+
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
233
|
+
const headers = new Headers({
|
|
234
|
+
"User-Agent": EXTERNAL_USER_AGENT,
|
|
235
|
+
"Accept": "*/*",
|
|
236
|
+
});
|
|
237
|
+
const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
|
|
238
|
+
if (cookieHeader) {
|
|
239
|
+
headers.set("Cookie", cookieHeader);
|
|
240
|
+
}
|
|
241
|
+
const response = await fetch(currentUrl, { headers, redirect: "manual" });
|
|
242
|
+
const setCookies = response.headers
|
|
243
|
+
.getSetCookie?.() ?? [];
|
|
244
|
+
for (const cookie of setCookies) {
|
|
245
|
+
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
246
|
+
}
|
|
247
|
+
if (!setCookies.length) {
|
|
248
|
+
const cookie = response.headers.get("set-cookie");
|
|
249
|
+
if (cookie)
|
|
250
|
+
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
251
|
+
}
|
|
252
|
+
if (response.status < 300 || response.status >= 400) {
|
|
253
|
+
return response;
|
|
254
|
+
}
|
|
255
|
+
const location = response.headers.get("location");
|
|
256
|
+
await response.body?.cancel();
|
|
257
|
+
if (!location) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const nextUrl = new URL(location, currentUrl);
|
|
261
|
+
if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
currentUrl = nextUrl;
|
|
265
|
+
}
|
|
266
|
+
throw new Error("External resource exceeded the redirect limit");
|
|
267
|
+
}
|
|
149
268
|
function safeJson(text) {
|
|
150
269
|
try {
|
|
151
270
|
return JSON.parse(text);
|
|
@@ -154,3 +273,10 @@ function safeJson(text) {
|
|
|
154
273
|
return {};
|
|
155
274
|
}
|
|
156
275
|
}
|
|
276
|
+
function isoDateToFinnish(date) {
|
|
277
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
278
|
+
if (!match) {
|
|
279
|
+
return date;
|
|
280
|
+
}
|
|
281
|
+
return `${match[3]}.${match[2]}.${match[1]}`;
|
|
282
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder, Municipality, NewsItem, OverviewData, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
|
|
1
|
+
export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, 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";
|
package/dist/parsers/news.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NewsItem } from "../types.js";
|
|
2
2
|
export declare function parseNewsList(data: unknown): NewsItem[];
|
|
3
|
-
export declare function parseNewsDetailJson(newsId: number, data: Record<string, unknown
|
|
4
|
-
export declare function parseNewsDetailHtml(html: string, newsId: number): NewsItem;
|
|
3
|
+
export declare function parseNewsDetailJson(newsId: number, data: Record<string, unknown>, baseUrl?: string): NewsItem;
|
|
4
|
+
export declare function parseNewsDetailHtml(html: string, newsId: number, baseUrl?: string): NewsItem;
|
|
5
5
|
export declare function parseNewsListHtml(html: string): NewsItem[];
|
package/dist/parsers/news.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as cheerio from "cheerio";
|
|
2
2
|
import { parseWilmaTimestamp } from "./dates.js";
|
|
3
|
+
const FILE_EXTENSION_RE = /\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|zip|7z|png|jpe?g|gif|webp)$/i;
|
|
3
4
|
export function parseNewsList(data) {
|
|
4
5
|
if (Array.isArray(data)) {
|
|
5
6
|
const now = new Date();
|
|
@@ -24,18 +25,25 @@ export function parseNewsList(data) {
|
|
|
24
25
|
}
|
|
25
26
|
return [];
|
|
26
27
|
}
|
|
27
|
-
export function parseNewsDetailJson(newsId, data) {
|
|
28
|
+
export function parseNewsDetailJson(newsId, data, baseUrl) {
|
|
29
|
+
const content = (data["content"] ?? data["Content"]);
|
|
30
|
+
let resources = [];
|
|
31
|
+
if (content && content.includes("<a")) {
|
|
32
|
+
const $ = cheerio.load(content);
|
|
33
|
+
resources = extractNewsResources($, $.root(), baseUrl);
|
|
34
|
+
}
|
|
28
35
|
return {
|
|
29
36
|
wilmaId: newsId,
|
|
30
37
|
title: String(data["title"] ?? data["Title"] ?? ""),
|
|
31
38
|
subtitle: (data["subtitle"] ?? data["Subtitle"]),
|
|
32
39
|
author: (data["author"] ?? data["Author"]),
|
|
33
40
|
published: parseWilmaTimestamp(data["Published"] ?? data["published"]),
|
|
34
|
-
content
|
|
41
|
+
content,
|
|
42
|
+
resources,
|
|
35
43
|
fetchedAt: new Date(),
|
|
36
44
|
};
|
|
37
45
|
}
|
|
38
|
-
export function parseNewsDetailHtml(html, newsId) {
|
|
46
|
+
export function parseNewsDetailHtml(html, newsId, baseUrl) {
|
|
39
47
|
const $ = cheerio.load(html);
|
|
40
48
|
let title = $("title").text().trim();
|
|
41
49
|
if (title.endsWith(" - Wilma")) {
|
|
@@ -48,10 +56,23 @@ export function parseNewsDetailHtml(html, newsId) {
|
|
|
48
56
|
const subtitleElem = $("p.sub-text, .subtitle").first();
|
|
49
57
|
const subtitle = subtitleElem.length ? subtitleElem.text().trim() : null;
|
|
50
58
|
let content = null;
|
|
51
|
-
|
|
59
|
+
let resources = [];
|
|
60
|
+
// Wilma can hide link-only bulletin content when it renders the target in an
|
|
61
|
+
// iframe. Prefer the dedicated container even when it carries .hidden.
|
|
62
|
+
const dedicatedContent = $("#news-content").first();
|
|
63
|
+
const contentElem = dedicatedContent.length
|
|
64
|
+
? dedicatedContent
|
|
65
|
+
: $(".news-content, .content, .ckeditor, article, .panel-body").first();
|
|
52
66
|
if (contentElem.length) {
|
|
53
|
-
contentElem
|
|
54
|
-
|
|
67
|
+
resources = extractNewsResources($, contentElem, baseUrl);
|
|
68
|
+
const cleanContent = contentElem.clone();
|
|
69
|
+
cleanContent.find("script, style").remove();
|
|
70
|
+
const text = cleanContent.text().trim();
|
|
71
|
+
// A container made entirely from resource anchors has no prose body. Keep
|
|
72
|
+
// the labels in resources instead of duplicating them in content.
|
|
73
|
+
const proseOnly = cleanContent.clone();
|
|
74
|
+
proseOnly.find("a[href]").remove();
|
|
75
|
+
content = proseOnly.text().trim() ? text : null;
|
|
55
76
|
}
|
|
56
77
|
return {
|
|
57
78
|
wilmaId: newsId,
|
|
@@ -60,9 +81,62 @@ export function parseNewsDetailHtml(html, newsId) {
|
|
|
60
81
|
author: null,
|
|
61
82
|
published: null,
|
|
62
83
|
content,
|
|
84
|
+
resources,
|
|
63
85
|
fetchedAt: new Date(),
|
|
64
86
|
};
|
|
65
87
|
}
|
|
88
|
+
function extractNewsResources($, contentElem, baseUrl) {
|
|
89
|
+
const resources = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
contentElem.find("a[href]").each((_, element) => {
|
|
92
|
+
const anchor = $(element);
|
|
93
|
+
const rawHref = (anchor.attr("href") ?? "").trim();
|
|
94
|
+
const url = resolveSafeHttpUrl(rawHref, baseUrl);
|
|
95
|
+
if (!url || seen.has(url.href)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
seen.add(url.href);
|
|
99
|
+
const authContext = baseUrl && url.origin === new URL(baseUrl).origin ? "wilma" : "external";
|
|
100
|
+
// Only a naming hint — never used to decide whether a download may be
|
|
101
|
+
// attempted. The download attempt itself reveals whether a URL is a file.
|
|
102
|
+
const fileName = FILE_EXTENSION_RE.test(decodeURIComponentSafely(url.pathname))
|
|
103
|
+
? fileNameFromUrl(url)
|
|
104
|
+
: null;
|
|
105
|
+
const label = anchor.text().trim() || fileName || url.host;
|
|
106
|
+
resources.push({
|
|
107
|
+
id: `resource-${resources.length + 1}`,
|
|
108
|
+
label,
|
|
109
|
+
url: url.href,
|
|
110
|
+
authContext,
|
|
111
|
+
fileName,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return resources;
|
|
115
|
+
}
|
|
116
|
+
function resolveSafeHttpUrl(rawHref, baseUrl) {
|
|
117
|
+
if (!rawHref || rawHref.startsWith("#")) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const url = baseUrl ? new URL(rawHref, baseUrl) : new URL(rawHref);
|
|
122
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function fileNameFromUrl(url) {
|
|
129
|
+
const lastSegment = url.pathname.split("/").filter(Boolean).at(-1);
|
|
130
|
+
return lastSegment ? decodeURIComponentSafely(lastSegment) : null;
|
|
131
|
+
}
|
|
132
|
+
function decodeURIComponentSafely(value) {
|
|
133
|
+
try {
|
|
134
|
+
return decodeURIComponent(value);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
66
140
|
export function parseNewsListHtml(html) {
|
|
67
141
|
const $ = cheerio.load(html);
|
|
68
142
|
const now = new Date();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
export function parseScheduleHtml(html) {
|
|
3
|
+
const $ = cheerio.load(html);
|
|
4
|
+
const scripts = $("script:not([src])")
|
|
5
|
+
.map((_, el) => $(el).html() ?? "")
|
|
6
|
+
.get();
|
|
7
|
+
const script = scripts.find((text) => text.includes("eventsJSON") && text.includes("Events"));
|
|
8
|
+
if (!script) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const match = /Events\s*:\s*(\[[\s\S]*?\])\s*,\s*ActiveTyyppi/.exec(script);
|
|
12
|
+
if (!match) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
let events;
|
|
16
|
+
try {
|
|
17
|
+
events = JSON.parse(match[1]);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return events
|
|
23
|
+
.map(parseScheduleEvent)
|
|
24
|
+
.filter((lesson) => lesson !== null)
|
|
25
|
+
.sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start));
|
|
26
|
+
}
|
|
27
|
+
function parseScheduleEvent(event) {
|
|
28
|
+
const date = finnishDateToIso(event.Date ?? "");
|
|
29
|
+
if (!date) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const subject = firstRecordValue(event.Text) || firstRecordValue(event.LongText);
|
|
33
|
+
const teacher = firstTeacher(event.OpeInfo);
|
|
34
|
+
const dateObj = new Date(`${date}T12:00:00`);
|
|
35
|
+
return {
|
|
36
|
+
date,
|
|
37
|
+
dayOfWeek: dateObj.getDay(),
|
|
38
|
+
start: minutesToTime(event.Start),
|
|
39
|
+
end: minutesToTime(event.End),
|
|
40
|
+
subject,
|
|
41
|
+
subjectCode: firstSubjectToken(subject),
|
|
42
|
+
teacher: teacher.name,
|
|
43
|
+
teacherCode: teacher.code,
|
|
44
|
+
groupId: Number(event.Id) || 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function finnishDateToIso(value) {
|
|
48
|
+
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value.trim());
|
|
49
|
+
if (!match) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
return `${match[3]}-${match[2].padStart(2, "0")}-${match[1].padStart(2, "0")}`;
|
|
53
|
+
}
|
|
54
|
+
function minutesToTime(value) {
|
|
55
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
const hours = Math.floor(value / 60);
|
|
59
|
+
const minutes = value % 60;
|
|
60
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
61
|
+
}
|
|
62
|
+
function firstRecordValue(record) {
|
|
63
|
+
if (!record) {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
return Object.values(record).find((value) => value.trim()) ?? "";
|
|
67
|
+
}
|
|
68
|
+
function firstTeacher(groups) {
|
|
69
|
+
if (!groups) {
|
|
70
|
+
return { name: "", code: "" };
|
|
71
|
+
}
|
|
72
|
+
for (const group of Object.values(groups)) {
|
|
73
|
+
for (const person of Object.values(group)) {
|
|
74
|
+
if (person?.nimi || person?.lyhenne) {
|
|
75
|
+
return { name: person.nimi ?? "", code: person.lyhenne ?? "" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { name: "", code: "" };
|
|
80
|
+
}
|
|
81
|
+
function firstSubjectToken(subject) {
|
|
82
|
+
return subject.trim().split(/\s+/, 1)[0] ?? "";
|
|
83
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -19,8 +19,17 @@ export interface NewsItem {
|
|
|
19
19
|
author?: string | null;
|
|
20
20
|
published?: Date | null;
|
|
21
21
|
content?: string | null;
|
|
22
|
+
resources?: NewsResource[];
|
|
22
23
|
fetchedAt: Date;
|
|
23
24
|
}
|
|
25
|
+
export type NewsResourceAuthContext = "external" | "wilma";
|
|
26
|
+
export interface NewsResource {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
url: string;
|
|
30
|
+
authContext: NewsResourceAuthContext;
|
|
31
|
+
fileName?: string | null;
|
|
32
|
+
}
|
|
24
33
|
export interface Exam {
|
|
25
34
|
wilmaId: number;
|
|
26
35
|
examDate: Date;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wilm-ai/wilma-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
28
|
"lint": "echo 'add lint'",
|
|
29
|
-
"test": "pnpm run build && node test/parser-messages.mjs",
|
|
29
|
+
"test": "pnpm run build && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/client-schedule.mjs && node test/client-news-resources.mjs",
|
|
30
30
|
"test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
|
|
31
31
|
"test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
|
|
32
32
|
}
|