somtoday-mcp 0.1.1 → 0.3.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/README.md +9 -6
- package/dist/api/assignments.d.ts +19 -1
- package/dist/api/assignments.js +55 -6
- package/dist/api/client.d.ts +8 -0
- package/dist/api/client.js +25 -3
- package/dist/api/download.d.ts +2 -0
- package/dist/api/download.js +45 -37
- package/dist/api/lessons.d.ts +3 -0
- package/dist/api/lessons.js +3 -0
- package/dist/api/misc.d.ts +16 -0
- package/dist/api/misc.js +34 -1
- package/dist/api/schedule.d.ts +3 -0
- package/dist/api/schedule.js +30 -9
- package/dist/api/types.d.ts +5 -0
- package/dist/server.js +22 -5
- package/dist/tools/assignments.d.ts +2 -0
- package/dist/tools/assignments.js +17 -6
- package/dist/tools/misc.d.ts +3 -0
- package/dist/tools/misc.js +7 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -17,11 +17,11 @@ It runs locally on your computer over stdio. Nothing is sent anywhere except to
|
|
|
17
17
|
| Tool | What you get |
|
|
18
18
|
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
19
19
|
| `whoami` | Your school label, the linked children with their numeric ids, and which portal sections are visible to you. |
|
|
20
|
-
| `lessons` | The timetable for one day, and whether that day is a school holiday.
|
|
20
|
+
| `lessons` | The timetable for one day, and whether that day is a school holiday. Each lesson has `type` (`ROOSTERTOETS`/`EXAMEN` = test) and `uitgevallen` when cancelled; homework and test topics come from `homework` for the same date. |
|
|
21
21
|
| `homework` | What to do and learn on which day: every study-planner item for a date range (default today + 6 days, max 42), grouped by day, with description, learning goals, time estimate, test type, attachments (links), external links, hand-in window/status and the done tick. |
|
|
22
22
|
| `grades` | The most recent results (up to 30) and averages per subject. |
|
|
23
23
|
| `assignments` | All hand-in assignments with window, status (upcoming, to do, waiting for review, approved), late flag, description, attachments and links. `status=open` keeps the ones still to do. |
|
|
24
|
-
| `assignment_detail` |
|
|
24
|
+
| `assignment_detail` | One assignment as the parent portal shows it: instruction, files, links, hand-in window and status. Submitted files and teacher reactions are only visible in the student's own session. |
|
|
25
25
|
| `study_material` | Study material per subject: lesson material from the planner by date, year attachments by folder (file links), external links, and methods/publisher links. No arguments lists the subjects; `vak` picks one; `all=true` fetches every subject. |
|
|
26
26
|
| `schedule` | Timetable for a date range (default today + 6 days, max 42): time, lesson hour, subject, room, teachers, type (test, exam, parent evening …), lesson notes, changes and cancellations, attachments, holidays. |
|
|
27
27
|
| `grades_latest` | Newest results first with subject, column, description, grade, weight, test type, period, date, remarks and resits; `limit` (default 50, max 1000) and `dossier=examen` for SE results. |
|
|
@@ -35,7 +35,8 @@ It runs locally on your computer over stdio. Nothing is sent anywhere except to
|
|
|
35
35
|
| `children` | Linked children with profile, class, school data (mentors, year, programme), current placement and visible portal sections. |
|
|
36
36
|
| `school_info` | School contact data and the school's information pages; `paginaId` opens one page. |
|
|
37
37
|
| `teachers` | Staff you can message, with the subjects they teach your child. |
|
|
38
|
-
| `signing_requests` | Digital signing requests (read-only; nothing is signed).
|
|
38
|
+
| `signing_requests` | Digital signing requests (read-only; nothing is signed). Each item has `document: {tool: "signing_document", id, available}`. |
|
|
39
|
+
| `signing_document` | The (signed) PDF of one signing request, as the portal's download button: pass `document.id`; returns extracted text, max 10 MB. Fetched with your login token, so it is not a `fileUrl` for `download_attachment`. |
|
|
39
40
|
| `parent_evening` | Parent-evening details (slots, teachers); finds invitations in your messages (read-only; nothing is booked). |
|
|
40
41
|
| `consents` | Consents such as use of photos, as set in the portal (read-only). |
|
|
41
42
|
| `download_attachment` | Read an attachment by `fileUrl`: https Somtoday hosts only (extend with `SOMTODAY_MCP_ATTACHMENT_HOSTS`), max 10 MB, no token sent; text from PDF/text/HTML/CSV/JSON. |
|
|
@@ -43,9 +44,11 @@ It runs locally on your computer over stdio. Nothing is sent anywhere except to
|
|
|
43
44
|
|
|
44
45
|
- `lessons` takes an optional `date` (`YYYY-MM-DD`, Europe/Amsterdam) and defaults to **tomorrow**. Range tools take `date`, or `from`/`to`.
|
|
45
46
|
- If more than one child is linked to your account, pass `leerlingId` (the numeric id from `whoami`).
|
|
47
|
+
- `assignments` with `status=open` hides past-due items that expect no digital hand-in (`inleveringenVerwacht: 0`, e.g. handed in on paper) and reports the count in `verborgenZonderInlevering`; pass `includeNoHandIn=true` to see them.
|
|
48
|
+
- `assignments` merges near-duplicates (same subject, topic, hand-in window and status, e.g. one assignment published to two groups) into one item with the other ids in `duplicaatToekenningIds`; pass `dedupe=false` to list them separately.
|
|
46
49
|
- If a section is switched off for your account, the tool says so and does not call that part of the API.
|
|
47
50
|
|
|
48
|
-
Not included:
|
|
51
|
+
Not included: anything that changes data, such as sending messages, reporting absence, booking parent-evening slots, signing requests, or uploading hand-ins.
|
|
49
52
|
|
|
50
53
|
## Read-only and privacy
|
|
51
54
|
|
|
@@ -59,9 +62,9 @@ Timetables, homework, and grades are personal data about children. Keep the outp
|
|
|
59
62
|
|
|
60
63
|
## Install
|
|
61
64
|
|
|
62
|
-
Requires Node.js 22 or newer.
|
|
65
|
+
Requires Node.js 22 or newer (the PDF text extraction used by `download_attachment` and `signing_document` does not run on Node 20).
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
Published on npm as [`somtoday-mcp`](https://www.npmjs.com/package/somtoday-mcp), with [provenance](https://docs.npmjs.com/generating-provenance-statements) from this repository's release workflow.
|
|
65
68
|
|
|
66
69
|
```bash
|
|
67
70
|
npx -y somtoday-mcp # start the MCP server (used by your MCP client)
|
|
@@ -5,11 +5,29 @@ export type AssignmentView = StudiewijzerItemView & {
|
|
|
5
5
|
categorie?: AssignmentCategory;
|
|
6
6
|
teLaat?: boolean;
|
|
7
7
|
};
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Same buckets as the portal's assignment list. `teLaat` is always a boolean
|
|
10
|
+
* once an item has a window: true only for an open hand-in (IN_TE_LEVEREN)
|
|
11
|
+
* whose deadline has passed.
|
|
12
|
+
*/
|
|
9
13
|
export declare function categorize(item: StudiewijzerItemView, now: Date): {
|
|
10
14
|
categorie?: AssignmentCategory;
|
|
11
15
|
teLaat?: boolean;
|
|
12
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* A past-due open item where Somtoday expects no digital hand-in
|
|
19
|
+
* (`inleveringenVerwacht: 0`, e.g. handed in on paper or in class). It stays
|
|
20
|
+
* IN_TE_LEVEREN with teLaat forever, so `status=open` hides it by default.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isPastWithoutHandIn(item: AssignmentView): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Collapse near-duplicates: the same assignment published to several groups
|
|
25
|
+
* shows up with different toekenningIds but the same subject, topic, hand-in
|
|
26
|
+
* window and category. Keeps the first and lists the others' ids.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dedupeAssignments(items: AssignmentView[]): Array<AssignmentView & {
|
|
29
|
+
duplicaatToekenningIds?: number[];
|
|
30
|
+
}>;
|
|
13
31
|
export declare function fetchAssignments(client: ApiClient, leerlingId: number, now: Date): Promise<AssignmentView[]>;
|
|
14
32
|
/**
|
|
15
33
|
* One assignment from exactly the list `assignments` returns (same request,
|
package/dist/api/assignments.js
CHANGED
|
@@ -3,7 +3,11 @@ import { htmlToPlain } from "../html.js";
|
|
|
3
3
|
import { handInQuery } from "./homework.js";
|
|
4
4
|
import { linkId } from "./simplify.js";
|
|
5
5
|
import { compact, compactItem, mapAttachment, mapStudiewijzerItem, } from "./studiewijzer.js";
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Same buckets as the portal's assignment list. `teLaat` is always a boolean
|
|
8
|
+
* once an item has a window: true only for an open hand-in (IN_TE_LEVEREN)
|
|
9
|
+
* whose deadline has passed.
|
|
10
|
+
*/
|
|
7
11
|
export function categorize(item, now) {
|
|
8
12
|
const start = item.inlevermoment?.start
|
|
9
13
|
? new Date(item.inlevermoment.start)
|
|
@@ -14,20 +18,65 @@ export function categorize(item, now) {
|
|
|
14
18
|
if (!start)
|
|
15
19
|
return {};
|
|
16
20
|
if (start > now)
|
|
17
|
-
return { categorie: "AANKOMEND" };
|
|
21
|
+
return { categorie: "AANKOMEND", teLaat: false };
|
|
18
22
|
const status = item.inleverStatus;
|
|
19
23
|
if (!status || status === "HEROPEND") {
|
|
20
24
|
return {
|
|
21
25
|
categorie: "IN_TE_LEVEREN",
|
|
22
|
-
teLaat: eind ? eind < now :
|
|
26
|
+
teLaat: eind ? eind < now : false,
|
|
23
27
|
};
|
|
24
28
|
}
|
|
25
29
|
if (status === "TE_BEOORDELEN" || status === "IN_BEHANDELING") {
|
|
26
|
-
return { categorie: "IN_AFWACHTING" };
|
|
30
|
+
return { categorie: "IN_AFWACHTING", teLaat: false };
|
|
27
31
|
}
|
|
28
32
|
if (status === "AKKOORD")
|
|
29
|
-
return { categorie: "AKKOORD" };
|
|
30
|
-
return {};
|
|
33
|
+
return { categorie: "AKKOORD", teLaat: false };
|
|
34
|
+
return { teLaat: false };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A past-due open item where Somtoday expects no digital hand-in
|
|
38
|
+
* (`inleveringenVerwacht: 0`, e.g. handed in on paper or in class). It stays
|
|
39
|
+
* IN_TE_LEVEREN with teLaat forever, so `status=open` hides it by default.
|
|
40
|
+
*/
|
|
41
|
+
export function isPastWithoutHandIn(item) {
|
|
42
|
+
return (item.categorie === "IN_TE_LEVEREN" &&
|
|
43
|
+
item.teLaat === true &&
|
|
44
|
+
item.inlevermoment?.inleveringenVerwacht === 0);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Collapse near-duplicates: the same assignment published to several groups
|
|
48
|
+
* shows up with different toekenningIds but the same subject, topic, hand-in
|
|
49
|
+
* window and category. Keeps the first and lists the others' ids.
|
|
50
|
+
*/
|
|
51
|
+
export function dedupeAssignments(items) {
|
|
52
|
+
const kept = new Map();
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const key = item.onderwerp
|
|
56
|
+
? JSON.stringify([
|
|
57
|
+
item.vakUuid ?? item.vak ?? "",
|
|
58
|
+
item.onderwerp.trim().toLowerCase(),
|
|
59
|
+
item.inlevermoment?.start ?? "",
|
|
60
|
+
item.inlevermoment?.eind ?? "",
|
|
61
|
+
item.categorie ?? "",
|
|
62
|
+
])
|
|
63
|
+
: undefined;
|
|
64
|
+
const first = key ? kept.get(key) : undefined;
|
|
65
|
+
if (first) {
|
|
66
|
+
if (item.toekenningId !== undefined) {
|
|
67
|
+
first.duplicaatToekenningIds = [
|
|
68
|
+
...(first.duplicaatToekenningIds ?? []),
|
|
69
|
+
item.toekenningId,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const copy = { ...item };
|
|
75
|
+
if (key)
|
|
76
|
+
kept.set(key, copy);
|
|
77
|
+
out.push(copy);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
31
80
|
}
|
|
32
81
|
export async function fetchAssignments(client, leerlingId, now) {
|
|
33
82
|
const { items } = await client.getAll("/studiewijzeritemdagtoekenningen", handInQuery(leerlingId));
|
package/dist/api/client.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type ApiResponse<T> = {
|
|
|
8
8
|
export type GetOptions = {
|
|
9
9
|
/** `json` for the endpoints that expect plain application/json (news, school info, signing). */
|
|
10
10
|
accept?: "platinum" | "json";
|
|
11
|
+
/** Raw Accept header; overrides `accept` (binary downloads such as `application/pdf`). */
|
|
12
|
+
acceptHeader?: string;
|
|
11
13
|
/** Inclusive item range, sent as `Range: items=start-end`. */
|
|
12
14
|
range?: {
|
|
13
15
|
start: number;
|
|
@@ -40,6 +42,12 @@ export declare class ApiClient {
|
|
|
40
42
|
private readonly fetchFn;
|
|
41
43
|
constructor(session: SessionManager, fetchFn?: FetchFn);
|
|
42
44
|
get<T>(path: string, query?: URLSearchParams, options?: GetOptions): Promise<ApiResponse<T>>;
|
|
45
|
+
/**
|
|
46
|
+
* Authenticated binary GET (e.g. a signed PDF). Returns the raw response
|
|
47
|
+
* after the same token-renewal retry as `get`; the caller reads the body.
|
|
48
|
+
*/
|
|
49
|
+
getRaw(path: string, acceptHeader: string, query?: URLSearchParams): Promise<Response>;
|
|
50
|
+
private authorizedGet;
|
|
43
51
|
/**
|
|
44
52
|
* Fetch every item of a list endpoint the way the portal does: request
|
|
45
53
|
* `Range: items=a-b` in pages of at most 100 and continue while
|
package/dist/api/client.js
CHANGED
|
@@ -25,19 +25,40 @@ export class ApiClient {
|
|
|
25
25
|
this.fetchFn = fetchFn;
|
|
26
26
|
}
|
|
27
27
|
async get(path, query, options = {}) {
|
|
28
|
+
const response = await this.authorizedGet(path, query, options);
|
|
29
|
+
return this.read(response, path);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Authenticated binary GET (e.g. a signed PDF). Returns the raw response
|
|
33
|
+
* after the same token-renewal retry as `get`; the caller reads the body.
|
|
34
|
+
*/
|
|
35
|
+
async getRaw(path, acceptHeader, query) {
|
|
36
|
+
const response = await this.authorizedGet(path, query, { acceptHeader });
|
|
37
|
+
if (response.status === 403 || response.status === 404) {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
throw new ApiNotAvailableError(response.status, path);
|
|
40
|
+
}
|
|
41
|
+
if (response.status !== 200) {
|
|
42
|
+
await response.body?.cancel();
|
|
43
|
+
throw new Error(`API HTTP ${response.status}`);
|
|
44
|
+
}
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
async authorizedGet(path, query, options) {
|
|
28
48
|
const url = await this.url(path, query);
|
|
29
49
|
const first = await this.send(url, options);
|
|
30
50
|
if (!(await shouldRetryWithNewToken(first.response))) {
|
|
31
|
-
return
|
|
51
|
+
return first.response;
|
|
32
52
|
}
|
|
33
53
|
// 401, or a 403 without a body: renew the access token (once) and retry once.
|
|
54
|
+
await first.response.body?.cancel().catch(() => undefined);
|
|
34
55
|
await this.session.refreshAfterRejection(first.token);
|
|
35
56
|
const retry = await this.send(url, options);
|
|
36
57
|
if (retry.response.status === 401) {
|
|
37
58
|
this.session.clearAccessToken();
|
|
38
59
|
throw new LoginRequiredError("API rejected a freshly renewed token");
|
|
39
60
|
}
|
|
40
|
-
return
|
|
61
|
+
return retry.response;
|
|
41
62
|
}
|
|
42
63
|
/**
|
|
43
64
|
* Fetch every item of a list endpoint the way the portal does: request
|
|
@@ -94,7 +115,8 @@ export class ApiClient {
|
|
|
94
115
|
const token = await this.session.getAccessToken();
|
|
95
116
|
const headers = {
|
|
96
117
|
Authorization: `Bearer ${token}`,
|
|
97
|
-
Accept: options.
|
|
118
|
+
Accept: options.acceptHeader ??
|
|
119
|
+
(options.accept === "json" ? ACCEPT_JSON : ACCEPT),
|
|
98
120
|
};
|
|
99
121
|
if (options.range) {
|
|
100
122
|
headers.Range = `items=${options.range.start}-${options.range.end}`;
|
package/dist/api/download.d.ts
CHANGED
|
@@ -19,3 +19,5 @@ export declare function downloadAttachment(fetchFn: typeof fetch, rawUrl: string
|
|
|
19
19
|
maxBytes?: number;
|
|
20
20
|
env?: string;
|
|
21
21
|
}): Promise<Attachment>;
|
|
22
|
+
/** Read a successful response (size-capped) and extract text where possible. */
|
|
23
|
+
export declare function readFile(response: Response, url: URL, maxBytes?: number, fallbackName?: string): Promise<Attachment>;
|
package/dist/api/download.js
CHANGED
|
@@ -26,7 +26,7 @@ export function isAllowedUrl(raw, env = process.env[ATTACHMENT_HOSTS_ENV]) {
|
|
|
26
26
|
? host.endsWith(rule) && host.length > rule.length
|
|
27
27
|
: host === rule);
|
|
28
28
|
}
|
|
29
|
-
function fileNameFrom(disposition, url) {
|
|
29
|
+
function fileNameFrom(disposition, url, fromPath = true) {
|
|
30
30
|
const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
|
|
31
31
|
if (match?.[1]) {
|
|
32
32
|
try {
|
|
@@ -36,6 +36,8 @@ function fileNameFrom(disposition, url) {
|
|
|
36
36
|
return match[1];
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
if (!fromPath)
|
|
40
|
+
return undefined;
|
|
39
41
|
const last = url.pathname.split("/").pop();
|
|
40
42
|
return last ? decodeURIComponent(last) : undefined;
|
|
41
43
|
}
|
|
@@ -111,43 +113,49 @@ export async function downloadAttachment(fetchFn, rawUrl, options = {}) {
|
|
|
111
113
|
await response.body?.cancel();
|
|
112
114
|
throw new AttachmentError(`download failed: HTTP ${response.status}`);
|
|
113
115
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
116
|
+
return readFile(response, url, options.maxBytes);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Read a successful response (size-capped) and extract text where possible. */
|
|
120
|
+
export async function readFile(response, url, maxBytes = ATTACHMENT_MAX_BYTES, fallbackName) {
|
|
121
|
+
const bytes = await readCapped(response, maxBytes);
|
|
122
|
+
const contentType = (response.headers.get("content-type") ?? "application/octet-stream")
|
|
123
|
+
.split(";")[0]
|
|
124
|
+
.trim()
|
|
125
|
+
.toLowerCase();
|
|
126
|
+
const fileName = fileNameFrom(response.headers.get("content-disposition"), url, false) ??
|
|
127
|
+
fallbackName ??
|
|
128
|
+
fileNameFrom(null, url);
|
|
129
|
+
const base = {
|
|
130
|
+
host: url.hostname,
|
|
131
|
+
contentType,
|
|
132
|
+
fileName,
|
|
133
|
+
bytes: bytes.byteLength,
|
|
134
|
+
};
|
|
135
|
+
const isPdf = contentType === "application/pdf" ||
|
|
136
|
+
(contentType === "application/octet-stream" &&
|
|
137
|
+
/\.pdf$/i.test(fileName ?? ""));
|
|
138
|
+
if (isPdf) {
|
|
139
|
+
try {
|
|
140
|
+
const { text, pages } = await extractPdf(bytes);
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
pages,
|
|
144
|
+
...truncate(text),
|
|
145
|
+
note: text ? undefined : "PDF has no text layer (scanned?)",
|
|
146
|
+
};
|
|
142
147
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const text = /html/.test(contentType) ? htmlToPlain(decoded) : decoded;
|
|
146
|
-
return { ...base, ...truncate(text) };
|
|
148
|
+
catch {
|
|
149
|
+
return { ...base, note: "could not read PDF text" };
|
|
147
150
|
}
|
|
148
|
-
return {
|
|
149
|
-
...base,
|
|
150
|
-
note: "binary file; text extraction not supported for this type",
|
|
151
|
-
};
|
|
152
151
|
}
|
|
152
|
+
if (TEXT_TYPES.test(contentType)) {
|
|
153
|
+
const decoded = new TextDecoder("utf-8").decode(bytes);
|
|
154
|
+
const text = /html/.test(contentType) ? htmlToPlain(decoded) : decoded;
|
|
155
|
+
return { ...base, ...truncate(text) };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
note: "binary file; text extraction not supported for this type",
|
|
160
|
+
};
|
|
153
161
|
}
|
package/dist/api/lessons.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type LessonView = {
|
|
|
12
12
|
afkorting?: string;
|
|
13
13
|
};
|
|
14
14
|
docentNamen?: string[];
|
|
15
|
+
/** Appointment type, e.g. LES, ROOSTERTOETS (a test in the timetable), EXAMEN. */
|
|
16
|
+
type?: string;
|
|
17
|
+
uitgevallen?: boolean;
|
|
15
18
|
};
|
|
16
19
|
export declare function filterLessonsForDate(items: AfspraakItem[], ymd: string): LessonView[];
|
|
17
20
|
export declare function holidaysOnDate(items: HolidayItem[], ymd: string): HolidayItem[];
|
package/dist/api/lessons.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dateInInclusiveRange, isoWeekParts, sameAmsterdamDate, } from "../dates.js";
|
|
2
|
+
import { CANCELLED_STATUS } from "./schedule.js";
|
|
2
3
|
export function filterLessonsForDate(items, ymd) {
|
|
3
4
|
return items
|
|
4
5
|
.filter((item) => item.beginDatumTijd && sameAmsterdamDate(item.beginDatumTijd, ymd))
|
|
@@ -13,6 +14,8 @@ export function filterLessonsForDate(items, ymd) {
|
|
|
13
14
|
? { naam: item.vak.naam, afkorting: item.vak.afkorting }
|
|
14
15
|
: undefined,
|
|
15
16
|
docentNamen: item.docentNamen,
|
|
17
|
+
type: item.afspraakItemType,
|
|
18
|
+
uitgevallen: item.statusNotifications?.some((note) => note.status === CANCELLED_STATUS) || undefined,
|
|
16
19
|
}));
|
|
17
20
|
}
|
|
18
21
|
export function holidaysOnDate(items, ymd) {
|
package/dist/api/misc.d.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import type { ApiClient } from "./client.js";
|
|
2
|
+
import { type Attachment } from "./download.js";
|
|
3
|
+
/**
|
|
4
|
+
* How to fetch the document behind a signing request. The portal downloads it
|
|
5
|
+
* from `begeleiden/ondertekenverzoeken/{id}/document` (PDF, needs the login
|
|
6
|
+
* token), so it is not a public fileUrl: use the `signing_document` tool.
|
|
7
|
+
*/
|
|
8
|
+
export declare function signingDocumentRef(item: unknown): {
|
|
9
|
+
tool: "signing_document";
|
|
10
|
+
id: string | number;
|
|
11
|
+
available: boolean;
|
|
12
|
+
} | undefined;
|
|
2
13
|
/** Digital signing requests (`begeleiden/ondertekenverzoeken`, plain JSON). Read-only: signing is never done here. */
|
|
3
14
|
export declare function fetchSigningRequests(client: ApiClient): Promise<unknown[]>;
|
|
15
|
+
/**
|
|
16
|
+
* The (signed) document of one signing request, as the portal's
|
|
17
|
+
* "download document" button fetches it. Read-only GET with the login token.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fetchSigningDocument(client: ApiClient, id: string): Promise<Attachment>;
|
|
4
20
|
/** Consents (photo use etc.) as shown under Voorkeuren → Toestemmingen. Read-only. */
|
|
5
21
|
export declare function fetchConsents(client: ApiClient): Promise<unknown[]>;
|
|
6
22
|
export declare function fetchParentEvening(client: ApiClient, id: string): Promise<unknown>;
|
package/dist/api/misc.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
|
+
import { readFile } from "./download.js";
|
|
1
2
|
import { fetchConversations } from "./messages.js";
|
|
2
3
|
import { simplify } from "./simplify.js";
|
|
4
|
+
/** Statuses where the portal shows no document (still to sign, withdrawn, expired). */
|
|
5
|
+
const NO_DOCUMENT_STATUSES = new Set(["OPENSTAAND", "INGETROKKEN", "VERLOPEN"]);
|
|
6
|
+
/**
|
|
7
|
+
* How to fetch the document behind a signing request. The portal downloads it
|
|
8
|
+
* from `begeleiden/ondertekenverzoeken/{id}/document` (PDF, needs the login
|
|
9
|
+
* token), so it is not a public fileUrl: use the `signing_document` tool.
|
|
10
|
+
*/
|
|
11
|
+
export function signingDocumentRef(item) {
|
|
12
|
+
const source = item;
|
|
13
|
+
const id = source?.id;
|
|
14
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
15
|
+
return undefined;
|
|
16
|
+
const status = typeof source?.status === "string" ? source.status : "";
|
|
17
|
+
return {
|
|
18
|
+
tool: "signing_document",
|
|
19
|
+
id,
|
|
20
|
+
available: !NO_DOCUMENT_STATUSES.has(status.toUpperCase()),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
3
23
|
/** Digital signing requests (`begeleiden/ondertekenverzoeken`, plain JSON). Read-only: signing is never done here. */
|
|
4
24
|
export async function fetchSigningRequests(client) {
|
|
5
25
|
const { body } = await client.get("/begeleiden/ondertekenverzoeken", undefined, {
|
|
@@ -8,7 +28,20 @@ export async function fetchSigningRequests(client) {
|
|
|
8
28
|
const items = Array.isArray(body)
|
|
9
29
|
? body
|
|
10
30
|
: (body?.items ?? []);
|
|
11
|
-
return items.map((item) =>
|
|
31
|
+
return items.map((item) => {
|
|
32
|
+
const out = (simplify(item) ?? {});
|
|
33
|
+
const document = signingDocumentRef(out);
|
|
34
|
+
return document ? { ...out, document } : out;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The (signed) document of one signing request, as the portal's
|
|
39
|
+
* "download document" button fetches it. Read-only GET with the login token.
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchSigningDocument(client, id) {
|
|
42
|
+
const path = `/begeleiden/ondertekenverzoeken/${encodeURIComponent(id)}/document`;
|
|
43
|
+
const response = await client.getRaw(path, "application/pdf");
|
|
44
|
+
return readFile(response, new URL(response.url || `https://api.somtoday.nl${path}`), undefined, `ondertekenverzoek-${id}.pdf`);
|
|
12
45
|
}
|
|
13
46
|
/** Consents (photo use etc.) as shown under Voorkeuren → Toestemmingen. Read-only. */
|
|
14
47
|
export async function fetchConsents(client) {
|
package/dist/api/schedule.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ type RawAfspraak = {
|
|
|
32
32
|
inschrijfStatus?: unknown;
|
|
33
33
|
kwtInfo?: unknown;
|
|
34
34
|
};
|
|
35
|
+
/** The portal marks an appointment as cancelled (uitgevallen) with this status. */
|
|
36
|
+
export declare const CANCELLED_STATUS = "4007";
|
|
35
37
|
export type ScheduleItemView = {
|
|
36
38
|
id?: number;
|
|
37
39
|
begin?: string;
|
|
@@ -47,6 +49,7 @@ export type ScheduleItemView = {
|
|
|
47
49
|
omschrijving?: string;
|
|
48
50
|
wijziging?: string;
|
|
49
51
|
meldingen?: RawStatus[];
|
|
52
|
+
uitgevallen?: boolean;
|
|
50
53
|
online?: boolean;
|
|
51
54
|
bijlagen?: AttachmentView[];
|
|
52
55
|
keuzewerktijd?: unknown;
|
package/dist/api/schedule.js
CHANGED
|
@@ -2,6 +2,24 @@ import { amsterdamYmd, dayOf, eachDay, isoWeeksInRange } from "../dates.js";
|
|
|
2
2
|
import { htmlToPlain } from "../html.js";
|
|
3
3
|
import { linkId, simplify } from "./simplify.js";
|
|
4
4
|
import { compact, mapAttachment, } from "./studiewijzer.js";
|
|
5
|
+
/** "Inschrijving in orde." – informational, repeated on every item; not a warning. */
|
|
6
|
+
const INFO_STATUSES = new Set(["2002"]);
|
|
7
|
+
/** The portal marks an appointment as cancelled (uitgevallen) with this status. */
|
|
8
|
+
export const CANCELLED_STATUS = "4007";
|
|
9
|
+
function realMeldingen(notes) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const note of notes ?? []) {
|
|
13
|
+
if (note.status && INFO_STATUSES.has(note.status))
|
|
14
|
+
continue;
|
|
15
|
+
const key = `${note.status ?? ""}|${note.message ?? ""}`;
|
|
16
|
+
if (seen.has(key))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
out.push(compact({ status: note.status, message: note.message }));
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
5
23
|
export function mapScheduleItem(raw) {
|
|
6
24
|
const lesuur = raw.beginLesuur === undefined
|
|
7
25
|
? undefined
|
|
@@ -29,7 +47,8 @@ export function mapScheduleItem(raw) {
|
|
|
29
47
|
.filter((naam) => Boolean(naam)),
|
|
30
48
|
omschrijving: htmlToPlain(raw.omschrijving) || undefined,
|
|
31
49
|
wijziging: raw.wijzigingOmschrijving || undefined,
|
|
32
|
-
meldingen: raw.statusNotifications
|
|
50
|
+
meldingen: realMeldingen(raw.statusNotifications),
|
|
51
|
+
uitgevallen: raw.statusNotifications?.some((note) => note.status === CANCELLED_STATUS) || undefined,
|
|
33
52
|
online: raw.onlineDeelname || undefined,
|
|
34
53
|
bijlagen: (raw.bijlagen ?? [])
|
|
35
54
|
.map(mapAttachment)
|
|
@@ -44,15 +63,14 @@ function holidayBounds(holiday) {
|
|
|
44
63
|
];
|
|
45
64
|
}
|
|
46
65
|
export async function fetchSchedule(client, leerlingId, from, to) {
|
|
47
|
-
const
|
|
66
|
+
const notes = [];
|
|
48
67
|
const weeks = await Promise.all(isoWeeksInRange(from, to).map(({ year, week }) => client.getAll(`/afspraakitems/${leerlingId}/jaar/${year}/week/${week}`, undefined, {
|
|
49
68
|
onPage: (body) => {
|
|
50
|
-
|
|
51
|
-
?.statusNotifications ?? [])
|
|
52
|
-
meldingen.push(compact({ status: note.status, message: note.message }));
|
|
53
|
-
}
|
|
69
|
+
notes.push(...(body
|
|
70
|
+
?.statusNotifications ?? []));
|
|
54
71
|
},
|
|
55
72
|
})));
|
|
73
|
+
const meldingen = realMeldingen(notes);
|
|
56
74
|
const holidays = (await client.getAll("/vakanties/verzorger"))
|
|
57
75
|
.items;
|
|
58
76
|
const items = weeks
|
|
@@ -70,9 +88,12 @@ export async function fetchSchedule(client, leerlingId, from, to) {
|
|
|
70
88
|
to,
|
|
71
89
|
meldingen,
|
|
72
90
|
vakanties,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
// Every day keeps `items` (also when empty), like homework.
|
|
92
|
+
days: eachDay(from, to).map((datum) => ({
|
|
93
|
+
...compact({
|
|
94
|
+
datum,
|
|
95
|
+
vakantie: vakanties.find((holiday) => holiday.van <= datum && datum <= holiday.tot)?.naam,
|
|
96
|
+
}),
|
|
76
97
|
items: items.filter((item) => item.begin && amsterdamYmd(item.begin) === datum),
|
|
77
98
|
})),
|
|
78
99
|
};
|
package/dist/api/types.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { absenceTool } from "./tools/absence.js";
|
|
|
16
16
|
import { messagesTool } from "./tools/messages.js";
|
|
17
17
|
import { announcementsTool, newsTool } from "./tools/news.js";
|
|
18
18
|
import { childrenTool, schoolInfoTool, teachersTool } from "./tools/profile.js";
|
|
19
|
-
import { consentsTool, parentEveningTool, signingRequestsTool, } from "./tools/misc.js";
|
|
19
|
+
import { consentsTool, parentEveningTool, signingDocumentTool, signingRequestsTool, } from "./tools/misc.js";
|
|
20
20
|
import { downloadAttachmentTool } from "./tools/download.js";
|
|
21
21
|
import { scheduleTool } from "./tools/schedule.js";
|
|
22
22
|
import { studyMaterialTool } from "./tools/study-material.js";
|
|
@@ -59,12 +59,12 @@ export function createServer(session = createDefaultSession()) {
|
|
|
59
59
|
}, async () => whoamiTool(ctx));
|
|
60
60
|
server.registerTool("lessons", {
|
|
61
61
|
title: "Lessons",
|
|
62
|
-
description: "Timetable for one Amsterdam date, plus whether that date is a holiday.",
|
|
62
|
+
description: "Timetable for one Amsterdam date, plus whether that date is a holiday. Each lesson has type (ROOSTERTOETS or EXAMEN marks a test in the timetable) and uitgevallen=true when cancelled. Homework and the test topic per lesson are not here: call homework for the same date.",
|
|
63
63
|
inputSchema: { date: dateArg, leerlingId: leerlingArg() },
|
|
64
64
|
}, async ({ date, leerlingId }) => lessonsTool(ctx, { date, leerlingId }));
|
|
65
65
|
server.registerTool("schedule", {
|
|
66
66
|
title: "Schedule",
|
|
67
|
-
description: "Timetable for a date range, per day: lessons, tests and other appointments with time, lesson hour, subject, room, teachers, type (e.g. ROOSTERTOETS, EXAMEN, OUDERAVOND), lesson notes, changes and cancellations (meldingen
|
|
67
|
+
description: "Timetable for a date range, per day: lessons, tests and other appointments with time, lesson hour, subject, room, teachers, type (e.g. ROOSTERTOETS, EXAMEN, OUDERAVOND), lesson notes, changes and cancellations (wijziging, uitgevallen=true, meldingen with real warnings only; the routine 'Inschrijving in orde.' notice is dropped), attachments, and holidays. Every day has items (empty array on a free day). Defaults to today plus 6 days; at most 42 days.",
|
|
68
68
|
inputSchema: {
|
|
69
69
|
...rangeArgs("Defaults to today."),
|
|
70
70
|
leerlingId: leerlingArg(),
|
|
@@ -81,10 +81,18 @@ export function createServer(session = createDefaultSession()) {
|
|
|
81
81
|
}, async (args) => homeworkTool(ctx, args));
|
|
82
82
|
server.registerTool("assignments", {
|
|
83
83
|
title: "Assignments (hand-ins)",
|
|
84
|
-
description: "All hand-in assignments with their window, status (AANKOMEND, IN_TE_LEVEREN, IN_AFWACHTING, AKKOORD),
|
|
84
|
+
description: "All hand-in assignments with their window, status (AANKOMEND, IN_TE_LEVEREN, IN_AFWACHTING, AKKOORD), teLaat (always a boolean: true only for an open hand-in past its deadline), description, attachments and links. status=open keeps upcoming and to-do ones, and by default hides past-due items where no digital hand-in is expected (inleveringenVerwacht 0, e.g. on paper; count in verborgenZonderInlevering); includeNoHandIn=true keeps them. Near-duplicates (same subject, topic, window and status, e.g. published to two groups) are merged by default, with the other ids in duplicaatToekenningIds; dedupe=false lists them separately. Use toekenningId with assignment_detail." +
|
|
85
85
|
" Every item lists bijlagen (fileUrl, readable with download_attachment) and externeMaterialen; empty means the teacher attached nothing (lesmateriaal=true is only a flag, there is no separate material call).",
|
|
86
86
|
inputSchema: {
|
|
87
87
|
status: z.enum(["open", "all"]).optional().describe("Default all."),
|
|
88
|
+
includeNoHandIn: z
|
|
89
|
+
.boolean()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("With status=open: also show past-due items without a digital hand-in (inleveringenVerwacht 0). Default false."),
|
|
92
|
+
dedupe: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Merge near-duplicate assignments (same subject, topic, window, status). Default true."),
|
|
88
96
|
leerlingId: leerlingArg(),
|
|
89
97
|
},
|
|
90
98
|
}, async (args) => assignmentsTool(ctx, args));
|
|
@@ -232,9 +240,18 @@ export function createServer(session = createDefaultSession()) {
|
|
|
232
240
|
}, async () => teachersTool(ctx));
|
|
233
241
|
server.registerTool("signing_requests", {
|
|
234
242
|
title: "Signing requests",
|
|
235
|
-
description: "Digital signing requests (ondertekenverzoeken) waiting for you or done. Read-only; nothing is signed.",
|
|
243
|
+
description: "Digital signing requests (ondertekenverzoeken) waiting for you or done. Each item has document {tool:'signing_document', id, available}: pass id to signing_document to read the (signed) PDF. Read-only; nothing is signed.",
|
|
236
244
|
inputSchema: {},
|
|
237
245
|
}, async () => signingRequestsTool(ctx));
|
|
246
|
+
server.registerTool("signing_document", {
|
|
247
|
+
title: "Signing document",
|
|
248
|
+
description: "Read the document (PDF) of one signing request, as the portal's download button does: pass document.id from signing_requests. Returns extracted text (max 10 MB). The document needs your login, so it is not a fileUrl for download_attachment. Usually available once signed; not for requests still to sign, withdrawn or expired.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
id: textOrNumber()
|
|
251
|
+
.pipe(z.string().regex(/^[A-Za-z0-9-]+$/))
|
|
252
|
+
.describe("Signing request id (document.id from signing_requests)."),
|
|
253
|
+
},
|
|
254
|
+
}, async (args) => signingDocumentTool(ctx, args));
|
|
238
255
|
server.registerTool("parent_evening", {
|
|
239
256
|
title: "Parent evening",
|
|
240
257
|
description: "Parent-evening (ouderavond) details: invitation, time slots and chosen teachers. Without id, finds invitations linked in your messages. Read-only; nothing is booked.",
|
|
@@ -2,6 +2,8 @@ import type { ToolContext } from "./context.js";
|
|
|
2
2
|
import type { ToolResult } from "./result.js";
|
|
3
3
|
export declare function assignmentsTool(ctx: ToolContext, args: {
|
|
4
4
|
status?: "open" | "all";
|
|
5
|
+
includeNoHandIn?: boolean;
|
|
6
|
+
dedupe?: boolean;
|
|
5
7
|
leerlingId?: string;
|
|
6
8
|
}, now?: Date): Promise<ToolResult>;
|
|
7
9
|
/**
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
import { fetchAssignments, findAssignment } from "../api/assignments.js";
|
|
1
|
+
import { dedupeAssignments, fetchAssignments, findAssignment, isPastWithoutHandIn, } from "../api/assignments.js";
|
|
2
2
|
import { childTool } from "./child-tool.js";
|
|
3
3
|
const OPEN = new Set(["AANKOMEND", "IN_TE_LEVEREN"]);
|
|
4
4
|
export async function assignmentsTool(ctx, args, now = new Date()) {
|
|
5
5
|
return childTool(ctx, args.leerlingId, "huiswerkBekijkenAan", async (child) => {
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
: all;
|
|
10
|
-
|
|
6
|
+
const fetched = await fetchAssignments(ctx.client, child.leerlingId, now);
|
|
7
|
+
const all = args.dedupe === false ? fetched : dedupeAssignments(fetched);
|
|
8
|
+
if (args.status !== "open") {
|
|
9
|
+
return { total: all.length, shown: all.length, items: all };
|
|
10
|
+
}
|
|
11
|
+
const open = all.filter((item) => item.categorie && OPEN.has(item.categorie));
|
|
12
|
+
const items = args.includeNoHandIn
|
|
13
|
+
? open
|
|
14
|
+
: open.filter((item) => !isPastWithoutHandIn(item));
|
|
15
|
+
const hidden = open.length - items.length;
|
|
16
|
+
return {
|
|
17
|
+
total: all.length,
|
|
18
|
+
shown: items.length,
|
|
19
|
+
...(hidden > 0 ? { verborgenZonderInlevering: hidden } : {}),
|
|
20
|
+
items,
|
|
21
|
+
};
|
|
11
22
|
});
|
|
12
23
|
}
|
|
13
24
|
/**
|
package/dist/tools/misc.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ToolContext } from "./context.js";
|
|
2
2
|
import { type ToolResult } from "./result.js";
|
|
3
3
|
export declare function signingRequestsTool(ctx: ToolContext): Promise<ToolResult>;
|
|
4
|
+
export declare function signingDocumentTool(ctx: ToolContext, args: {
|
|
5
|
+
id: string;
|
|
6
|
+
}): Promise<ToolResult>;
|
|
4
7
|
export declare function consentsTool(ctx: ToolContext): Promise<ToolResult>;
|
|
5
8
|
export declare function parentEveningTool(ctx: ToolContext, args: {
|
|
6
9
|
id?: string;
|
package/dist/tools/misc.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchConsents, fetchParentEvening, fetchSigningRequests, findParentEveningIds, } from "../api/misc.js";
|
|
1
|
+
import { fetchConsents, fetchParentEvening, fetchSigningDocument, fetchSigningRequests, findParentEveningIds, } from "../api/misc.js";
|
|
2
2
|
import { accountTool } from "./child-tool.js";
|
|
3
3
|
import { section } from "./result.js";
|
|
4
4
|
export async function signingRequestsTool(ctx) {
|
|
@@ -6,6 +6,12 @@ export async function signingRequestsTool(ctx) {
|
|
|
6
6
|
ondertekenverzoeken: await section(() => fetchSigningRequests(ctx.client)),
|
|
7
7
|
}));
|
|
8
8
|
}
|
|
9
|
+
export async function signingDocumentTool(ctx, args) {
|
|
10
|
+
return accountTool(ctx, async () => ({
|
|
11
|
+
id: args.id,
|
|
12
|
+
...(await fetchSigningDocument(ctx.client, args.id)),
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
9
15
|
export async function consentsTool(ctx) {
|
|
10
16
|
return accountTool(ctx, async () => ({
|
|
11
17
|
toestemmingen: await section(() => fetchConsents(ctx.client)),
|