chuvsu-js 4.2.0 → 5.0.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 +165 -193
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/common/cache.js +30 -9
- package/dist/common/http.d.ts +6 -0
- package/dist/common/http.js +8 -1
- package/dist/common/parse.d.ts +1 -1
- package/dist/common/parse.js +2 -2
- package/dist/common/types.d.ts +8 -2
- package/dist/common/types.js +12 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +5 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -4
- package/dist/lk/client.d.ts +8 -6
- package/dist/lk/client.js +51 -23
- package/dist/lk/types.d.ts +7 -7
- package/dist/node.d.ts +1 -0
- package/dist/node.js +1 -0
- package/dist/parsers.d.ts +3 -0
- package/dist/parsers.js +2 -0
- package/dist/tt/client.d.ts +55 -63
- package/dist/tt/client.js +477 -226
- package/dist/tt/domain/directory.d.ts +14 -0
- package/dist/tt/domain/directory.js +116 -0
- package/dist/tt/domain/ids.d.ts +7 -0
- package/dist/tt/domain/ids.js +20 -0
- package/dist/tt/domain/index.d.ts +7 -0
- package/dist/tt/domain/index.js +5 -0
- package/dist/tt/domain/normalize.d.ts +7 -0
- package/dist/tt/domain/normalize.js +65 -0
- package/dist/tt/domain/repository.d.ts +51 -0
- package/dist/tt/domain/repository.js +805 -0
- package/dist/tt/domain/schedule.d.ts +37 -0
- package/dist/tt/domain/schedule.js +292 -0
- package/dist/tt/domain/types.d.ts +175 -0
- package/dist/tt/domain/types.js +1 -0
- package/dist/tt/observations.d.ts +11 -0
- package/dist/tt/observations.js +131 -0
- package/dist/tt/parse/audience.d.ts +3 -3
- package/dist/tt/parse/audience.js +47 -15
- package/dist/tt/parse/entry-parts.d.ts +1 -0
- package/dist/tt/parse/entry-parts.js +5 -2
- package/dist/tt/parse/full-schedule.d.ts +9 -4
- package/dist/tt/parse/full-schedule.js +32 -41
- package/dist/tt/parse/groups.d.ts +1 -1
- package/dist/tt/parse/groups.js +1 -1
- package/dist/tt/parse/index.d.ts +4 -4
- package/dist/tt/parse/index.js +4 -4
- package/dist/tt/parse/lists.d.ts +6 -5
- package/dist/tt/parse/lists.js +7 -2
- package/dist/tt/parse/overlays.d.ts +5 -4
- package/dist/tt/parse/overlays.js +13 -14
- package/dist/tt/parse/teacher.d.ts +2 -3
- package/dist/tt/parse/teacher.js +14 -56
- package/dist/tt/parse/webinars.js +3 -4
- package/dist/tt/types.d.ts +54 -69
- package/dist/tt/utils/date.d.ts +4 -0
- package/dist/tt/utils/date.js +30 -1
- package/dist/tt/utils/index.d.ts +2 -3
- package/dist/tt/utils/index.js +2 -3
- package/dist/tt/utils/period.d.ts +4 -4
- package/dist/tt/utils/period.js +9 -8
- package/dist/tt/utils/semester.d.ts +4 -4
- package/dist/tt/utils/semester.js +25 -5
- package/dist/tt/utils/time-slots.d.ts +3 -5
- package/dist/tt/utils/time-slots.js +19 -34
- package/dist/tt/webinars.d.ts +7 -0
- package/dist/tt/webinars.js +38 -0
- package/docs/fixture-review.md +56 -0
- package/docs/testing.md +23 -0
- package/docs/v5-architecture.md +144 -0
- package/docs/v5-migration.md +451 -0
- package/package.json +17 -7
- package/dist/shared.d.ts +0 -9
- package/dist/shared.js +0 -7
- package/dist/tt/schedule.d.ts +0 -55
- package/dist/tt/schedule.js +0 -233
- package/dist/tt/utils/lessons.d.ts +0 -16
- package/dist/tt/utils/lessons.js +0 -173
package/dist/tt/client.js
CHANGED
|
@@ -1,29 +1,60 @@
|
|
|
1
|
-
import { HttpClient } from "../common/http.js";
|
|
1
|
+
import { HttpClient, } from "../common/http.js";
|
|
2
2
|
import { HybridCache } from "../common/cache.js";
|
|
3
|
-
import { AuthError, ParseError } from "../common/types.js";
|
|
4
|
-
import { parseAcademicYearFromPage,
|
|
5
|
-
import { Schedule } from "./schedule.js";
|
|
3
|
+
import { AcademicPeriod, AuthError, EducationLevel, ParseError, } from "../common/types.js";
|
|
4
|
+
import { parseAcademicYearFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseGroupButtons, parseGroupName, parseFacultyButtons, parseTeacherButtons, parseGroupSchedule, parseTeacherSchedule, parseTeacherInfo, parsePeriodFromPage, parseWebinars, } from "./parse/index.js";
|
|
5
|
+
import { Schedule } from "./domain/schedule.js";
|
|
6
|
+
import { TimetableRepository } from "./domain/repository.js";
|
|
7
|
+
import { createScheduleSourceSnapshot } from "./observations.js";
|
|
6
8
|
const BASE = "https://tt.chuvsu.ru";
|
|
7
9
|
const AUTH_URL = `${BASE}/auth`;
|
|
8
10
|
const TIMETABLE_CONTEXT_TTL_MS = 15 * 60 * 1000;
|
|
9
11
|
const ALL_PERIODS = [
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
AcademicPeriod.FallSemester,
|
|
13
|
+
AcademicPeriod.WinterSession,
|
|
14
|
+
AcademicPeriod.SpringSemester,
|
|
15
|
+
AcademicPeriod.SummerSession,
|
|
14
16
|
];
|
|
17
|
+
function requirePositiveId(value, label) {
|
|
18
|
+
if (!Number.isInteger(value) || value == null || value < 1) {
|
|
19
|
+
throw new RangeError(`${label} must be a positive integer`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function normalizeSearchQuery(value, label, minimumLength = 1) {
|
|
24
|
+
const normalized = value.trim();
|
|
25
|
+
if (normalized.length < minimumLength) {
|
|
26
|
+
throw new RangeError(`${label} must contain at least ${minimumLength} characters`);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
function normalizePeriods(periods) {
|
|
31
|
+
const values = [...(periods ?? ALL_PERIODS)];
|
|
32
|
+
if (values.length === 0)
|
|
33
|
+
throw new RangeError("At least one period is required");
|
|
34
|
+
const unique = new Set();
|
|
35
|
+
for (const period of values) {
|
|
36
|
+
if (!ALL_PERIODS.includes(period)) {
|
|
37
|
+
throw new RangeError(`Invalid academic period: ${period}`);
|
|
38
|
+
}
|
|
39
|
+
if (unique.has(period)) {
|
|
40
|
+
throw new RangeError(`Duplicate academic period: ${period}`);
|
|
41
|
+
}
|
|
42
|
+
unique.add(period);
|
|
43
|
+
}
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
15
46
|
function makeUniformCacheConfig(ttl) {
|
|
16
47
|
return {
|
|
17
48
|
schedule: ttl,
|
|
18
49
|
faculties: ttl,
|
|
19
50
|
groups: ttl,
|
|
20
|
-
|
|
21
|
-
|
|
51
|
+
rooms: ttl,
|
|
52
|
+
roomNames: ttl,
|
|
22
53
|
teachers: ttl,
|
|
23
54
|
teacherInfo: ttl,
|
|
24
55
|
teacherPhotos: ttl,
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
roomInfo: ttl,
|
|
57
|
+
roomImages: ttl,
|
|
27
58
|
webinars: ttl,
|
|
28
59
|
};
|
|
29
60
|
}
|
|
@@ -33,16 +64,26 @@ function formatDate(date) {
|
|
|
33
64
|
const day = String(date.getDate()).padStart(2, "0");
|
|
34
65
|
return `${year}-${month}-${day}`;
|
|
35
66
|
}
|
|
36
|
-
export class
|
|
67
|
+
export class TimetableClient {
|
|
37
68
|
http = new HttpClient();
|
|
38
|
-
|
|
69
|
+
educationLevel;
|
|
39
70
|
cache;
|
|
40
71
|
blobAdapter = undefined;
|
|
72
|
+
_repository;
|
|
73
|
+
repositoryAdapter;
|
|
74
|
+
repositoryReady = null;
|
|
75
|
+
repositoryMutation = Promise.resolve();
|
|
41
76
|
loginMode = null;
|
|
42
77
|
timetableContext = null;
|
|
43
78
|
constructor(opts) {
|
|
44
|
-
this.
|
|
79
|
+
this.educationLevel = opts?.educationLevel ?? EducationLevel.HigherEducation;
|
|
80
|
+
if (this.educationLevel !== EducationLevel.HigherEducation &&
|
|
81
|
+
this.educationLevel !== EducationLevel.VocationalEducation) {
|
|
82
|
+
throw new RangeError("Invalid education level");
|
|
83
|
+
}
|
|
45
84
|
this.blobAdapter = opts?.blobAdapter;
|
|
85
|
+
this._repository = opts?.repository ?? new TimetableRepository();
|
|
86
|
+
this.repositoryAdapter = opts?.repositoryAdapter;
|
|
46
87
|
if (opts?.cache == null) {
|
|
47
88
|
this.cache = null;
|
|
48
89
|
}
|
|
@@ -54,7 +95,62 @@ export class TtClient {
|
|
|
54
95
|
}
|
|
55
96
|
}
|
|
56
97
|
get pertt() {
|
|
57
|
-
return String(this.
|
|
98
|
+
return String(this.educationLevel);
|
|
99
|
+
}
|
|
100
|
+
get repository() {
|
|
101
|
+
return this._repository;
|
|
102
|
+
}
|
|
103
|
+
async ensureRepository() {
|
|
104
|
+
if (!this.repositoryAdapter)
|
|
105
|
+
return;
|
|
106
|
+
if (!this.repositoryReady) {
|
|
107
|
+
this.repositoryReady = (async () => {
|
|
108
|
+
const snapshot = await this.repositoryAdapter.load();
|
|
109
|
+
if (snapshot && this._repository.revision === 0) {
|
|
110
|
+
this._repository.replaceSnapshot(snapshot);
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
}
|
|
114
|
+
await this.repositoryReady;
|
|
115
|
+
}
|
|
116
|
+
async mutateRepository(mutate) {
|
|
117
|
+
const operation = this.repositoryMutation.then(() => this.performRepositoryMutation(mutate));
|
|
118
|
+
this.repositoryMutation = operation.then(() => undefined, () => undefined);
|
|
119
|
+
return operation;
|
|
120
|
+
}
|
|
121
|
+
async performRepositoryMutation(mutate) {
|
|
122
|
+
await this.ensureRepository();
|
|
123
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
124
|
+
const expectedRevision = this._repository.revision;
|
|
125
|
+
const result = mutate(this._repository);
|
|
126
|
+
if (!this.repositoryAdapter)
|
|
127
|
+
return result;
|
|
128
|
+
if (await this.repositoryAdapter.compareAndSet(expectedRevision, this._repository.export())) {
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
const latest = await this.repositoryAdapter.load();
|
|
132
|
+
this._repository.replaceSnapshot(latest ?? {
|
|
133
|
+
schemaVersion: 5,
|
|
134
|
+
revision: 0,
|
|
135
|
+
directory: { groups: [], teachers: [], rooms: [] },
|
|
136
|
+
sources: [],
|
|
137
|
+
links: [],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Timetable repository update conflict");
|
|
141
|
+
}
|
|
142
|
+
async exportRepository() {
|
|
143
|
+
await this.ensureRepository();
|
|
144
|
+
return this._repository.export();
|
|
145
|
+
}
|
|
146
|
+
async rememberGroups(values) {
|
|
147
|
+
await this.mutateRepository((repository) => repository.rememberGroups(values));
|
|
148
|
+
}
|
|
149
|
+
async rememberTeachers(values) {
|
|
150
|
+
await this.mutateRepository((repository) => repository.rememberTeachers(values));
|
|
151
|
+
}
|
|
152
|
+
async rememberRooms(values) {
|
|
153
|
+
await this.mutateRepository((repository) => repository.rememberRooms(values));
|
|
58
154
|
}
|
|
59
155
|
// --- Cache ---
|
|
60
156
|
async clearCache(category) {
|
|
@@ -93,6 +189,11 @@ export class TtClient {
|
|
|
93
189
|
isSessionExpired(body) {
|
|
94
190
|
return body.includes('name="wname"');
|
|
95
191
|
}
|
|
192
|
+
isBinarySessionExpired(response) {
|
|
193
|
+
const prefix = response.body.subarray(0, 8_192).toString("utf8");
|
|
194
|
+
return this.isSessionExpired(prefix) ||
|
|
195
|
+
response.contentType?.toLowerCase().includes("text/html") === true;
|
|
196
|
+
}
|
|
96
197
|
async relogin() {
|
|
97
198
|
if (!this.loginMode)
|
|
98
199
|
return;
|
|
@@ -104,21 +205,44 @@ export class TtClient {
|
|
|
104
205
|
}
|
|
105
206
|
}
|
|
106
207
|
async authGet(url) {
|
|
107
|
-
|
|
208
|
+
let res = await this.http.get(url);
|
|
108
209
|
if (this.loginMode && this.isSessionExpired(res.body)) {
|
|
109
210
|
await this.relogin();
|
|
110
|
-
|
|
211
|
+
res = await this.http.get(url);
|
|
212
|
+
}
|
|
213
|
+
if (this.isSessionExpired(res.body)) {
|
|
214
|
+
throw new AuthError("TT request returned an authentication page");
|
|
215
|
+
}
|
|
216
|
+
if (res.status < 200 || res.status >= 300) {
|
|
217
|
+
throw new Error(`TT request failed with HTTP ${res.status}: ${url}`);
|
|
111
218
|
}
|
|
112
219
|
return res;
|
|
113
220
|
}
|
|
114
221
|
async authGetBuffer(url) {
|
|
115
|
-
|
|
222
|
+
let res = await this.http.getBufferResponse(url);
|
|
223
|
+
if (this.loginMode && this.isBinarySessionExpired(res)) {
|
|
224
|
+
await this.relogin();
|
|
225
|
+
res = await this.http.getBufferResponse(url);
|
|
226
|
+
}
|
|
227
|
+
if (res.status < 200 || res.status >= 300) {
|
|
228
|
+
throw new Error(`TT request failed with HTTP ${res.status}: ${url}`);
|
|
229
|
+
}
|
|
230
|
+
if (this.isBinarySessionExpired(res)) {
|
|
231
|
+
throw new AuthError("TT binary request returned an authentication page");
|
|
232
|
+
}
|
|
233
|
+
return res.body;
|
|
116
234
|
}
|
|
117
235
|
async authPost(url, data) {
|
|
118
|
-
|
|
236
|
+
let res = await this.http.post(url, data);
|
|
119
237
|
if (this.loginMode && this.isSessionExpired(res.body)) {
|
|
120
238
|
await this.relogin();
|
|
121
|
-
|
|
239
|
+
res = await this.http.post(url, data);
|
|
240
|
+
}
|
|
241
|
+
if (this.isSessionExpired(res.body)) {
|
|
242
|
+
throw new AuthError("TT request returned an authentication page");
|
|
243
|
+
}
|
|
244
|
+
if (res.status < 200 || res.status >= 300) {
|
|
245
|
+
throw new Error(`TT request failed with HTTP ${res.status}: ${url}`);
|
|
122
246
|
}
|
|
123
247
|
return res;
|
|
124
248
|
}
|
|
@@ -146,42 +270,118 @@ export class TtClient {
|
|
|
146
270
|
return context;
|
|
147
271
|
}
|
|
148
272
|
// --- Schedule ---
|
|
149
|
-
async
|
|
150
|
-
const cacheKey =
|
|
273
|
+
async fetchGroupSchedule(groupId, period, academicYearStartYear) {
|
|
274
|
+
const cacheKey = `group:${groupId}:${period}:${academicYearStartYear}-${academicYearStartYear + 1}`;
|
|
151
275
|
const cached = await this.cache?.get("schedule", cacheKey);
|
|
152
|
-
if (cached)
|
|
153
|
-
|
|
276
|
+
if (cached) {
|
|
277
|
+
const page = cached;
|
|
278
|
+
await this.rememberGroups([{ id: groupId, name: page.ownerName }]);
|
|
279
|
+
return page.days;
|
|
280
|
+
}
|
|
154
281
|
const url = `${BASE}/index/grouptt/gr/${groupId}`;
|
|
155
282
|
const { body } = await this.authPost(url, { htype: String(period) });
|
|
156
|
-
const
|
|
157
|
-
|
|
283
|
+
const ownerName = this.validateSchedulePage({ type: "group", group: { id: groupId, name: "" } }, body, academicYearStartYear);
|
|
284
|
+
const days = parseGroupSchedule(body);
|
|
285
|
+
await this.rememberGroups([{ id: groupId, name: ownerName }]);
|
|
286
|
+
await this.cache?.set("schedule", cacheKey, { days, ownerName });
|
|
158
287
|
return days;
|
|
159
288
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
289
|
+
validateSchedulePage(owner, html, academicYearStartYear) {
|
|
290
|
+
const pageYear = parseAcademicYearFromPage(html);
|
|
291
|
+
if (pageYear !== academicYearStartYear) {
|
|
292
|
+
throw new ParseError("TT schedule response has an unexpected academic year");
|
|
293
|
+
}
|
|
294
|
+
const ownerName = owner.type === "group"
|
|
295
|
+
? parseGroupName(html)
|
|
296
|
+
: owner.type === "teacher"
|
|
297
|
+
? parseTeacherInfo(html)?.name ?? null
|
|
298
|
+
: parseRoomName(html);
|
|
299
|
+
if (!ownerName) {
|
|
300
|
+
throw new ParseError(`TT ${owner.type} schedule response has no owner name`);
|
|
301
|
+
}
|
|
302
|
+
return ownerName;
|
|
303
|
+
}
|
|
304
|
+
ownerUrl(owner) {
|
|
305
|
+
if (owner.type === "group") {
|
|
306
|
+
return `${BASE}/index/grouptt/gr/${requirePositiveId(owner.group.id, "Group ID")}`;
|
|
307
|
+
}
|
|
308
|
+
if (owner.type === "teacher") {
|
|
309
|
+
return `${BASE}/index/techtt/tech/${requirePositiveId(owner.teacher.id, "Teacher ID")}`;
|
|
310
|
+
}
|
|
311
|
+
return `${BASE}/index/audtt/aud/${requirePositiveId(owner.room.id, "Room ID")}`;
|
|
312
|
+
}
|
|
313
|
+
sourceKey(owner, period, academicYearStartYear) {
|
|
314
|
+
const entity = owner.type === "group"
|
|
315
|
+
? owner.group
|
|
316
|
+
: owner.type === "teacher"
|
|
317
|
+
? owner.teacher
|
|
318
|
+
: owner.room;
|
|
319
|
+
return `${owner.type}:${entity.id}:${period}:${academicYearStartYear}`;
|
|
320
|
+
}
|
|
321
|
+
resolveOwner(owner) {
|
|
322
|
+
if (owner.type === "group") {
|
|
323
|
+
return {
|
|
324
|
+
type: "group",
|
|
325
|
+
group: this._repository.directory.resolveGroup(owner.group),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (owner.type === "teacher") {
|
|
329
|
+
return {
|
|
330
|
+
type: "teacher",
|
|
331
|
+
teacher: this._repository.directory.resolveTeacher(owner.teacher),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
type: "room",
|
|
336
|
+
room: this._repository.directory.resolveRoom(owner.room),
|
|
337
|
+
};
|
|
175
338
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
339
|
+
async fetchOwnerSchedule(owner, period, academicYearStartYear) {
|
|
340
|
+
if (owner.type === "group") {
|
|
341
|
+
return this.fetchGroupSchedule(owner.group.id, period, academicYearStartYear);
|
|
342
|
+
}
|
|
343
|
+
if (owner.type === "teacher") {
|
|
344
|
+
return this.fetchTeacherSchedule(owner.teacher.id, period, academicYearStartYear);
|
|
345
|
+
}
|
|
346
|
+
return this.fetchRoomSchedule(owner.room.id, period, academicYearStartYear);
|
|
347
|
+
}
|
|
348
|
+
async getSchedule(owner, options) {
|
|
349
|
+
const ownerUrl = this.ownerUrl(owner);
|
|
350
|
+
const periods = normalizePeriods(options?.periods);
|
|
351
|
+
await this.ensureRepository();
|
|
352
|
+
if (owner.type === "group" && owner.group.name.trim()) {
|
|
353
|
+
await this.rememberGroups([owner.group]);
|
|
354
|
+
}
|
|
355
|
+
if (owner.type === "teacher" && owner.teacher.name.trim()) {
|
|
356
|
+
await this.rememberTeachers([owner.teacher]);
|
|
357
|
+
}
|
|
358
|
+
if (owner.type === "room" && owner.room.name.trim()) {
|
|
359
|
+
await this.rememberRooms([owner.room]);
|
|
360
|
+
}
|
|
361
|
+
const resolvedOwner = this.resolveOwner(owner);
|
|
362
|
+
const context = await this.getTimetableContext(ownerUrl);
|
|
363
|
+
const results = await Promise.all(periods.map(async (period) => ({
|
|
364
|
+
period,
|
|
365
|
+
days: await this.fetchOwnerSchedule(resolvedOwner, period, context.academicYearStartYear),
|
|
366
|
+
})));
|
|
367
|
+
for (const result of results) {
|
|
368
|
+
const currentOwner = this.resolveOwner(resolvedOwner);
|
|
369
|
+
const snapshot = createScheduleSourceSnapshot({
|
|
370
|
+
sourceKey: this.sourceKey(currentOwner, result.period, context.academicYearStartYear),
|
|
371
|
+
owner: currentOwner,
|
|
372
|
+
academicYearStartYear: context.academicYearStartYear,
|
|
373
|
+
period: result.period,
|
|
374
|
+
days: result.days,
|
|
375
|
+
});
|
|
376
|
+
await this.mutateRepository((repository) => repository.ingest(snapshot));
|
|
377
|
+
}
|
|
378
|
+
return new Schedule(this._repository, this.resolveOwner(resolvedOwner), context.academicYearStartYear, { period: periods.length === 1 ? periods[0] : context.period });
|
|
379
|
+
}
|
|
380
|
+
async getGroupSchedule(groupId, options) {
|
|
381
|
+
return this.getSchedule({
|
|
382
|
+
type: "group",
|
|
383
|
+
group: this._repository.directory.resolveGroup({ id: groupId, name: "" }),
|
|
384
|
+
}, options);
|
|
185
385
|
}
|
|
186
386
|
async getWebinars(opts) {
|
|
187
387
|
const date = opts?.date ?? new Date();
|
|
@@ -230,51 +430,66 @@ export class TtClient {
|
|
|
230
430
|
await this.cache?.set("faculties", "all", data);
|
|
231
431
|
return data;
|
|
232
432
|
}
|
|
233
|
-
async
|
|
234
|
-
|
|
433
|
+
async getFacultyGroups(facultyId) {
|
|
434
|
+
requirePositiveId(facultyId, "Faculty ID");
|
|
435
|
+
const cacheKey = String(facultyId);
|
|
235
436
|
const cached = await this.cache?.get("groups", cacheKey);
|
|
236
|
-
if (cached)
|
|
237
|
-
|
|
437
|
+
if (cached) {
|
|
438
|
+
const data = cached;
|
|
439
|
+
await this.rememberGroups(data);
|
|
440
|
+
return data;
|
|
441
|
+
}
|
|
238
442
|
const { body } = await this.authPost(`${BASE}/`, {
|
|
239
|
-
hfac: String(
|
|
443
|
+
hfac: String(facultyId),
|
|
240
444
|
pertt: this.pertt,
|
|
241
445
|
});
|
|
242
446
|
const data = parseGroupButtons(body);
|
|
243
447
|
await this.cache?.set("groups", cacheKey, data);
|
|
448
|
+
await this.rememberGroups(data);
|
|
244
449
|
return data;
|
|
245
450
|
}
|
|
246
|
-
async
|
|
247
|
-
|
|
451
|
+
async searchGroups(name) {
|
|
452
|
+
name = normalizeSearchQuery(name, "Group search query");
|
|
453
|
+
const cacheKey = `search:${name}:${this.pertt}`;
|
|
248
454
|
const cached = await this.cache?.get("groups", cacheKey);
|
|
249
|
-
if (cached)
|
|
250
|
-
|
|
455
|
+
if (cached) {
|
|
456
|
+
const data = cached;
|
|
457
|
+
await this.rememberGroups(data);
|
|
458
|
+
return data;
|
|
459
|
+
}
|
|
251
460
|
const { body } = await this.authPost(`${BASE}/`, {
|
|
252
|
-
grname:
|
|
461
|
+
grname: name,
|
|
253
462
|
findgr: "найти",
|
|
254
463
|
hfac: "0",
|
|
255
464
|
pertt: this.pertt,
|
|
256
465
|
});
|
|
257
466
|
const data = parseGroupButtons(body);
|
|
258
467
|
await this.cache?.set("groups", cacheKey, data);
|
|
468
|
+
await this.rememberGroups(data);
|
|
259
469
|
return data;
|
|
260
470
|
}
|
|
261
471
|
/**
|
|
262
472
|
* Search audiences by name (substring match). The server requires at
|
|
263
473
|
* least 3 characters in the query.
|
|
264
474
|
*/
|
|
265
|
-
async
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
475
|
+
async searchRooms(name) {
|
|
476
|
+
name = normalizeSearchQuery(name, "Room search query", 3);
|
|
477
|
+
const cacheKey = `search:${name}:${this.pertt}`;
|
|
478
|
+
const cached = await this.cache?.get("rooms", cacheKey);
|
|
479
|
+
if (cached) {
|
|
480
|
+
const data = cached;
|
|
481
|
+
await this.rememberRooms(data);
|
|
482
|
+
return data;
|
|
483
|
+
}
|
|
270
484
|
const { body } = await this.authPost(`${BASE}/`, {
|
|
271
|
-
audname:
|
|
485
|
+
audname: name,
|
|
272
486
|
findaud: "найти",
|
|
273
487
|
hfac: "0",
|
|
274
488
|
pertt: this.pertt,
|
|
275
489
|
});
|
|
276
|
-
const data =
|
|
277
|
-
await this.cache?.set("
|
|
490
|
+
const data = parseRoomButtons(body);
|
|
491
|
+
await this.cache?.set("rooms", cacheKey, data);
|
|
492
|
+
await this.rememberRooms(data);
|
|
278
493
|
return data;
|
|
279
494
|
}
|
|
280
495
|
/**
|
|
@@ -285,101 +500,157 @@ export class TtClient {
|
|
|
285
500
|
* 3-character wildcard `%%%` matches every audience at once and returns
|
|
286
501
|
* the full list of (id, name) pairs.
|
|
287
502
|
*/
|
|
288
|
-
async
|
|
503
|
+
async getRooms() {
|
|
289
504
|
const cacheKey = `all:${this.pertt}`;
|
|
290
|
-
const cached = await this.cache?.get("
|
|
291
|
-
if (cached)
|
|
292
|
-
|
|
505
|
+
const cached = await this.cache?.get("rooms", cacheKey);
|
|
506
|
+
if (cached) {
|
|
507
|
+
const data = cached;
|
|
508
|
+
await this.rememberRooms(data);
|
|
509
|
+
return data;
|
|
510
|
+
}
|
|
293
511
|
const { body } = await this.authPost(`${BASE}/`, {
|
|
294
512
|
audname: "%%%",
|
|
295
513
|
findaud: "найти",
|
|
296
514
|
hfac: "0",
|
|
297
515
|
pertt: this.pertt,
|
|
298
516
|
});
|
|
299
|
-
const data =
|
|
300
|
-
await this.cache?.set("
|
|
517
|
+
const data = parseRoomButtons(body);
|
|
518
|
+
await this.cache?.set("rooms", cacheKey, data);
|
|
519
|
+
await this.rememberRooms(data);
|
|
301
520
|
return data;
|
|
302
521
|
}
|
|
522
|
+
async resolveGroup(value, options) {
|
|
523
|
+
await this.ensureRepository();
|
|
524
|
+
const ref = typeof value === "string" ? { name: value } : value;
|
|
525
|
+
let resolved = this._repository.directory.resolveGroup(ref);
|
|
526
|
+
if (resolved.id == null && options?.strategy === "search") {
|
|
527
|
+
await this.searchGroups(ref.name);
|
|
528
|
+
resolved = this._repository.directory.resolveGroup(ref);
|
|
529
|
+
}
|
|
530
|
+
return resolved;
|
|
531
|
+
}
|
|
532
|
+
async resolveTeacher(value, options) {
|
|
533
|
+
await this.ensureRepository();
|
|
534
|
+
const ref = typeof value === "string" ? { name: value } : value;
|
|
535
|
+
let resolved = this._repository.directory.resolveTeacher(ref);
|
|
536
|
+
if (resolved.id == null && options?.strategy === "search") {
|
|
537
|
+
await this.searchTeachers(ref.name);
|
|
538
|
+
resolved = this._repository.directory.resolveTeacher(ref);
|
|
539
|
+
}
|
|
540
|
+
return resolved;
|
|
541
|
+
}
|
|
542
|
+
async resolveRoom(value, options) {
|
|
543
|
+
await this.ensureRepository();
|
|
544
|
+
const ref = typeof value === "string" ? { name: value } : value;
|
|
545
|
+
let resolved = this._repository.directory.resolveRoom(ref);
|
|
546
|
+
if (resolved.id == null && options?.strategy === "search") {
|
|
547
|
+
await this.searchRooms(ref.name.length >= 3 ? ref.name : "%%%");
|
|
548
|
+
resolved = this._repository.directory.resolveRoom(ref);
|
|
549
|
+
}
|
|
550
|
+
return resolved;
|
|
551
|
+
}
|
|
552
|
+
async preloadDirectory(options) {
|
|
553
|
+
const requests = [];
|
|
554
|
+
if (options.teachers)
|
|
555
|
+
requests.push(this.getTeachers());
|
|
556
|
+
if (options.rooms)
|
|
557
|
+
requests.push(this.getRooms());
|
|
558
|
+
for (const facultyId of options.facultyIds ?? []) {
|
|
559
|
+
requests.push(this.getFacultyGroups(facultyId));
|
|
560
|
+
}
|
|
561
|
+
await Promise.all(requests);
|
|
562
|
+
}
|
|
303
563
|
/**
|
|
304
564
|
* Resolve an audience id from its exact name by searching and
|
|
305
565
|
* selecting the button whose `value` equals the given name.
|
|
306
566
|
*/
|
|
307
|
-
async
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
567
|
+
async findRoomByName(name) {
|
|
568
|
+
name = normalizeSearchQuery(name, "Room name");
|
|
569
|
+
const query = name.length >= 3 ? name : "%%%";
|
|
570
|
+
const list = await this.searchRooms(query);
|
|
571
|
+
return list.find((room) => room.name === name) ?? null;
|
|
311
572
|
}
|
|
312
573
|
/** Fetch the audience's display name from its schedule page. */
|
|
313
|
-
async
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
574
|
+
async getRoomName(roomId) {
|
|
575
|
+
requirePositiveId(roomId, "Room ID");
|
|
576
|
+
const cacheKey = String(roomId);
|
|
577
|
+
const cached = await this.cache?.get("roomNames", cacheKey);
|
|
578
|
+
if (cached !== null && cached !== undefined) {
|
|
579
|
+
const name = cached;
|
|
580
|
+
if (name)
|
|
581
|
+
await this.rememberRooms([{ id: roomId, name }]);
|
|
582
|
+
return name;
|
|
321
583
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
584
|
+
const cachedInfo = await this.cache?.get("roomInfo", cacheKey);
|
|
585
|
+
if (cachedInfo) {
|
|
586
|
+
const name = cachedInfo.name ?? null;
|
|
587
|
+
if (name)
|
|
588
|
+
await this.rememberRooms([{ id: roomId, name }]);
|
|
589
|
+
return name;
|
|
590
|
+
}
|
|
591
|
+
const { body } = await this.authGet(`${BASE}/index/audtt/aud/${roomId}`);
|
|
592
|
+
const name = parseRoomName(body);
|
|
593
|
+
const info = parseRoomInfo(body);
|
|
594
|
+
await this.cache?.set("roomNames", cacheKey, name);
|
|
326
595
|
if (info)
|
|
327
|
-
await this.cache?.set("
|
|
596
|
+
await this.cache?.set("roomInfo", cacheKey, info);
|
|
597
|
+
if (name)
|
|
598
|
+
await this.rememberRooms([{ id: roomId, name }]);
|
|
328
599
|
return name;
|
|
329
600
|
}
|
|
330
601
|
/**
|
|
331
602
|
* Fetch detailed info about an audience (building, floor, usage,
|
|
332
603
|
* image URLs for the audience photo, building photo and floor plan).
|
|
333
604
|
*/
|
|
334
|
-
async
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
605
|
+
async getRoomInfo(roomId) {
|
|
606
|
+
requirePositiveId(roomId, "Room ID");
|
|
607
|
+
const cached = await this.cache?.get("roomInfo", String(roomId));
|
|
608
|
+
if (cached) {
|
|
609
|
+
const info = cached;
|
|
610
|
+
await this.rememberRooms([{ id: roomId, name: info.name }]);
|
|
611
|
+
return info;
|
|
612
|
+
}
|
|
613
|
+
const { body } = await this.authGet(`${BASE}/index/audtt/aud/${roomId}`);
|
|
614
|
+
const info = parseRoomInfo(body);
|
|
340
615
|
if (info)
|
|
341
|
-
await this.cache?.set("
|
|
616
|
+
await this.cache?.set("roomInfo", String(roomId), info);
|
|
617
|
+
if (info)
|
|
618
|
+
await this.rememberRooms([{ id: roomId, name: info.name }]);
|
|
342
619
|
return info;
|
|
343
620
|
}
|
|
344
|
-
async
|
|
345
|
-
const cacheKey = `
|
|
621
|
+
async fetchRoomSchedule(roomId, period, academicYearStartYear) {
|
|
622
|
+
const cacheKey = `room:${roomId}:${period}:${academicYearStartYear}-${academicYearStartYear + 1}`;
|
|
346
623
|
const cached = await this.cache?.get("schedule", cacheKey);
|
|
347
|
-
if (cached)
|
|
348
|
-
|
|
349
|
-
|
|
624
|
+
if (cached) {
|
|
625
|
+
const page = cached;
|
|
626
|
+
await this.rememberRooms([{ id: roomId, name: page.ownerName }]);
|
|
627
|
+
return page.days;
|
|
628
|
+
}
|
|
629
|
+
const url = `${BASE}/index/audtt/aud/${roomId}`;
|
|
350
630
|
const { body } = await this.authPost(url, { htype: String(period) });
|
|
351
|
-
const
|
|
352
|
-
|
|
631
|
+
const ownerName = this.validateSchedulePage({ type: "room", room: { id: roomId, name: "" } }, body, academicYearStartYear);
|
|
632
|
+
const days = parseRoomSchedule(body);
|
|
633
|
+
await this.rememberRooms([{ id: roomId, name: ownerName }]);
|
|
634
|
+
await this.cache?.set("schedule", cacheKey, { days, ownerName });
|
|
353
635
|
// Cache audience info from the same page to avoid an extra request.
|
|
354
|
-
if (!(await this.cache?.get("
|
|
355
|
-
const info =
|
|
636
|
+
if (!(await this.cache?.get("roomInfo", String(roomId)))) {
|
|
637
|
+
const info = parseRoomInfo(body);
|
|
356
638
|
if (info)
|
|
357
|
-
await this.cache?.set("
|
|
639
|
+
await this.cache?.set("roomInfo", String(roomId), info);
|
|
640
|
+
if (info)
|
|
641
|
+
await this.rememberRooms([{ id: roomId, name: info.name }]);
|
|
358
642
|
}
|
|
359
643
|
return days;
|
|
360
644
|
}
|
|
361
|
-
async
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
return new Schedule(audienceId, schedules, context.period, this.educationType, undefined, undefined, undefined, context.academicYearStartYear);
|
|
372
|
-
}
|
|
373
|
-
async getAudienceScheduleForPeriod(opts) {
|
|
374
|
-
const context = await this.getTimetableContext(`${BASE}/index/audtt/aud/${opts.audienceId}`);
|
|
375
|
-
const days = await this.fetchAudienceSchedule(opts.audienceId, opts.period, context.academicYearStartYear);
|
|
376
|
-
const schedules = new Map();
|
|
377
|
-
schedules.set(opts.period, days);
|
|
378
|
-
return new Schedule(opts.audienceId, schedules, opts.period, this.educationType, undefined, undefined, undefined, context.academicYearStartYear);
|
|
379
|
-
}
|
|
380
|
-
async getCachedAudienceImage(cacheKey, fetchUrl) {
|
|
381
|
-
const cached = this.cache?.getLocal("audienceImages", cacheKey) ??
|
|
382
|
-
await this.cache?.get("audienceImages", cacheKey);
|
|
645
|
+
async getRoomSchedule(roomId, options) {
|
|
646
|
+
return this.getSchedule({
|
|
647
|
+
type: "room",
|
|
648
|
+
room: this._repository.directory.resolveRoom({ id: roomId, name: "" }),
|
|
649
|
+
}, options);
|
|
650
|
+
}
|
|
651
|
+
async getCachedRoomImage(cacheKey, fetchUrl) {
|
|
652
|
+
const cached = this.cache?.getLocal("roomImages", cacheKey) ??
|
|
653
|
+
await this.cache?.get("roomImages", cacheKey);
|
|
383
654
|
if (cached !== null && cached !== undefined) {
|
|
384
655
|
const entry = cached;
|
|
385
656
|
if (entry.data !== undefined) {
|
|
@@ -388,7 +659,7 @@ export class TtClient {
|
|
|
388
659
|
if (entry.blobKey && this.blobAdapter) {
|
|
389
660
|
const buf = await this.blobAdapter.get(entry.blobKey);
|
|
390
661
|
if (buf) {
|
|
391
|
-
this.cache?.setLocal("
|
|
662
|
+
this.cache?.setLocal("roomImages", cacheKey, {
|
|
392
663
|
data: buf.toString("base64"),
|
|
393
664
|
});
|
|
394
665
|
return buf;
|
|
@@ -397,169 +668,149 @@ export class TtClient {
|
|
|
397
668
|
}
|
|
398
669
|
const url = await fetchUrl();
|
|
399
670
|
if (!url) {
|
|
400
|
-
await this.cache?.set("
|
|
671
|
+
await this.cache?.set("roomImages", cacheKey, { data: null });
|
|
401
672
|
return null;
|
|
402
673
|
}
|
|
403
674
|
const buf = await this.authGetBuffer(`${BASE}${url}`);
|
|
404
675
|
if (buf.length === 0) {
|
|
405
|
-
await this.cache?.set("
|
|
676
|
+
await this.cache?.set("roomImages", cacheKey, { data: null });
|
|
406
677
|
return null;
|
|
407
678
|
}
|
|
408
679
|
if (this.blobAdapter) {
|
|
409
|
-
const blobKey = `tt/
|
|
410
|
-
this.cache?.setLocal("
|
|
680
|
+
const blobKey = `tt/room-images/${cacheKey}`;
|
|
681
|
+
this.cache?.setLocal("roomImages", cacheKey, {
|
|
411
682
|
data: buf.toString("base64"),
|
|
412
683
|
});
|
|
413
684
|
await this.blobAdapter.put(blobKey, buf, {
|
|
414
|
-
ttl: this.cache?.ttl("
|
|
685
|
+
ttl: this.cache?.ttl("roomImages"),
|
|
415
686
|
});
|
|
416
|
-
await this.cache?.setExternal("
|
|
687
|
+
await this.cache?.setExternal("roomImages", cacheKey, { blobKey });
|
|
417
688
|
}
|
|
418
689
|
else {
|
|
419
|
-
await this.cache?.set("
|
|
690
|
+
await this.cache?.set("roomImages", cacheKey, {
|
|
420
691
|
data: buf.toString("base64"),
|
|
421
692
|
});
|
|
422
693
|
}
|
|
423
694
|
return buf;
|
|
424
695
|
}
|
|
425
696
|
/** Get the audience photo (audimage). Returns null if missing. */
|
|
426
|
-
async
|
|
427
|
-
|
|
697
|
+
async getRoomImage(roomId) {
|
|
698
|
+
requirePositiveId(roomId, "Room ID");
|
|
699
|
+
return this.getCachedRoomImage(`room:${roomId}`, async () => (await this.getRoomInfo(roomId))?.audImageUrl);
|
|
428
700
|
}
|
|
429
701
|
/** Get the building exterior image (blockimage). Returns null if missing. */
|
|
430
|
-
async
|
|
431
|
-
|
|
702
|
+
async getRoomBuildingImage(roomId) {
|
|
703
|
+
requirePositiveId(roomId, "Room ID");
|
|
704
|
+
return this.getCachedRoomImage(`block:${roomId}`, async () => (await this.getRoomInfo(roomId))?.blockImageUrl);
|
|
432
705
|
}
|
|
433
706
|
/** Get the floor plan image for the audience. Returns null if missing. */
|
|
434
|
-
async
|
|
435
|
-
|
|
707
|
+
async getRoomFloorPlan(roomId) {
|
|
708
|
+
requirePositiveId(roomId, "Room ID");
|
|
709
|
+
return this.getCachedRoomImage(`floor:${roomId}`, async () => (await this.getRoomInfo(roomId))?.floorplanUrl);
|
|
436
710
|
}
|
|
437
|
-
async
|
|
438
|
-
|
|
711
|
+
async searchTeachers(name) {
|
|
712
|
+
name = normalizeSearchQuery(name, "Teacher search query");
|
|
713
|
+
const cacheKey = `search:${name}:${this.pertt}`;
|
|
439
714
|
const cached = await this.cache?.get("teachers", cacheKey);
|
|
440
|
-
if (cached)
|
|
441
|
-
|
|
715
|
+
if (cached) {
|
|
716
|
+
const data = cached;
|
|
717
|
+
await this.rememberTeachers(data);
|
|
718
|
+
return data;
|
|
719
|
+
}
|
|
442
720
|
const { body } = await this.authPost(`${BASE}/`, {
|
|
443
|
-
techname:
|
|
721
|
+
techname: name,
|
|
444
722
|
findtech: "найти",
|
|
445
723
|
hfac: "0",
|
|
446
724
|
pertt: this.pertt,
|
|
447
725
|
});
|
|
448
726
|
const data = parseTeacherButtons(body);
|
|
449
727
|
await this.cache?.set("teachers", cacheKey, data);
|
|
728
|
+
await this.rememberTeachers(data);
|
|
450
729
|
return data;
|
|
451
730
|
}
|
|
452
731
|
// --- Teacher schedule ---
|
|
453
732
|
async getTeachers() {
|
|
454
733
|
const cached = await this.cache?.get("teachers", "all");
|
|
455
|
-
if (cached)
|
|
456
|
-
|
|
734
|
+
if (cached) {
|
|
735
|
+
const data = cached;
|
|
736
|
+
await this.rememberTeachers(data);
|
|
737
|
+
return data;
|
|
738
|
+
}
|
|
457
739
|
const { body } = await this.authGet(`${BASE}/index/tech`);
|
|
458
740
|
const data = parseTeacherButtons(body);
|
|
459
741
|
await this.cache?.set("teachers", "all", data);
|
|
742
|
+
await this.rememberTeachers(data);
|
|
460
743
|
return data;
|
|
461
744
|
}
|
|
462
745
|
async fetchTeacherSchedule(teacherId, period, academicYearStartYear) {
|
|
463
746
|
const cacheKey = `teacher:${teacherId}:${period}:${academicYearStartYear}-${academicYearStartYear + 1}`;
|
|
464
747
|
const cached = await this.cache?.get("schedule", cacheKey);
|
|
465
|
-
if (cached)
|
|
466
|
-
|
|
748
|
+
if (cached) {
|
|
749
|
+
const page = cached;
|
|
750
|
+
await this.rememberTeachers([{ id: teacherId, name: page.ownerName }]);
|
|
751
|
+
return page.days;
|
|
752
|
+
}
|
|
467
753
|
const url = `${BASE}/index/techtt/tech/${teacherId}`;
|
|
468
754
|
const { body } = await this.authPost(url, { htype: String(period) });
|
|
469
|
-
const
|
|
470
|
-
|
|
755
|
+
const ownerName = this.validateSchedulePage({ type: "teacher", teacher: { id: teacherId, name: "" } }, body, academicYearStartYear);
|
|
756
|
+
const days = parseTeacherSchedule(body);
|
|
757
|
+
await this.rememberTeachers([{ id: teacherId, name: ownerName }]);
|
|
758
|
+
await this.cache?.set("schedule", cacheKey, { days, ownerName });
|
|
471
759
|
// Cache teacher info from the same page to avoid extra requests
|
|
472
760
|
if (!(await this.cache?.get("teacherInfo", String(teacherId)))) {
|
|
473
761
|
const info = parseTeacherInfo(body);
|
|
474
762
|
if (info)
|
|
475
763
|
await this.cache?.set("teacherInfo", String(teacherId), info);
|
|
764
|
+
if (info) {
|
|
765
|
+
await this.rememberTeachers([
|
|
766
|
+
{
|
|
767
|
+
id: teacherId,
|
|
768
|
+
name: info.name,
|
|
769
|
+
degree: info.degree,
|
|
770
|
+
},
|
|
771
|
+
]);
|
|
772
|
+
}
|
|
476
773
|
}
|
|
477
774
|
return days;
|
|
478
775
|
}
|
|
479
|
-
async getTeacherSchedule(teacherId) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
schedules.set(period, days);
|
|
488
|
-
}
|
|
489
|
-
return new Schedule(teacherId, schedules, context.period, this.educationType, undefined, undefined, true, context.academicYearStartYear);
|
|
490
|
-
}
|
|
491
|
-
async getTeacherScheduleForPeriod(opts) {
|
|
492
|
-
const context = await this.getTimetableContext(`${BASE}/index/techtt/tech/${opts.teacherId}`);
|
|
493
|
-
const days = await this.fetchTeacherSchedule(opts.teacherId, opts.period, context.academicYearStartYear);
|
|
494
|
-
const schedules = new Map();
|
|
495
|
-
schedules.set(opts.period, days);
|
|
496
|
-
return new Schedule(opts.teacherId, schedules, opts.period, this.educationType, undefined, undefined, true, context.academicYearStartYear);
|
|
776
|
+
async getTeacherSchedule(teacherId, options) {
|
|
777
|
+
return this.getSchedule({
|
|
778
|
+
type: "teacher",
|
|
779
|
+
teacher: this._repository.directory.resolveTeacher({
|
|
780
|
+
id: teacherId,
|
|
781
|
+
name: "",
|
|
782
|
+
}),
|
|
783
|
+
}, options);
|
|
497
784
|
}
|
|
498
785
|
async getTeacherInfo(teacherId) {
|
|
786
|
+
requirePositiveId(teacherId, "Teacher ID");
|
|
499
787
|
const cached = await this.cache?.get("teacherInfo", String(teacherId));
|
|
500
|
-
if (cached)
|
|
501
|
-
|
|
788
|
+
if (cached) {
|
|
789
|
+
const info = cached;
|
|
790
|
+
await this.rememberTeachers([
|
|
791
|
+
{ id: teacherId, name: info.name, degree: info.degree },
|
|
792
|
+
]);
|
|
793
|
+
return info;
|
|
794
|
+
}
|
|
502
795
|
const url = `${BASE}/index/techtt/tech/${teacherId}`;
|
|
503
796
|
const { body } = await this.authGet(url);
|
|
504
797
|
const info = parseTeacherInfo(body);
|
|
505
798
|
if (info)
|
|
506
799
|
await this.cache?.set("teacherInfo", String(teacherId), info);
|
|
800
|
+
if (info) {
|
|
801
|
+
await this.rememberTeachers([
|
|
802
|
+
{ id: teacherId, name: info.name, degree: info.degree },
|
|
803
|
+
]);
|
|
804
|
+
}
|
|
507
805
|
return info;
|
|
508
806
|
}
|
|
509
807
|
/**
|
|
510
|
-
* Get
|
|
808
|
+
* Get teacher photo directly by known URL pattern.
|
|
809
|
+
* Does not fetch or parse teacher info first.
|
|
511
810
|
* Returns null if the teacher has no photo.
|
|
512
|
-
* Uses cached teacher info when available to avoid extra requests.
|
|
513
811
|
*/
|
|
514
812
|
async getTeacherPhoto(teacherId) {
|
|
515
|
-
|
|
516
|
-
const cachedPhoto = this.cache?.getLocal("teacherPhotos", photoCacheKey) ??
|
|
517
|
-
await this.cache?.get("teacherPhotos", photoCacheKey);
|
|
518
|
-
if (cachedPhoto !== null && cachedPhoto !== undefined) {
|
|
519
|
-
const entry = cachedPhoto;
|
|
520
|
-
if (entry.data !== undefined) {
|
|
521
|
-
return entry.data ? Buffer.from(entry.data, "base64") : null;
|
|
522
|
-
}
|
|
523
|
-
if (entry.blobKey && this.blobAdapter) {
|
|
524
|
-
const buf = await this.blobAdapter.get(entry.blobKey);
|
|
525
|
-
if (buf) {
|
|
526
|
-
this.cache?.setLocal("teacherPhotos", photoCacheKey, {
|
|
527
|
-
data: buf.toString("base64"),
|
|
528
|
-
});
|
|
529
|
-
return buf;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
// Get teacher info (may already be cached from schedule fetch)
|
|
534
|
-
const info = await this.getTeacherInfo(teacherId);
|
|
535
|
-
if (!info?.photoUrl) {
|
|
536
|
-
await this.cache?.set("teacherPhotos", photoCacheKey, { data: null });
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
const photoBuffer = await this.authGetBuffer(`${BASE}${info.photoUrl}`);
|
|
540
|
-
if (this.blobAdapter) {
|
|
541
|
-
const blobKey = `tt/teacher-photos/${teacherId}`;
|
|
542
|
-
this.cache?.setLocal("teacherPhotos", photoCacheKey, {
|
|
543
|
-
data: photoBuffer.toString("base64"),
|
|
544
|
-
});
|
|
545
|
-
await this.blobAdapter.put(blobKey, photoBuffer, {
|
|
546
|
-
ttl: this.cache?.ttl("teacherPhotos"),
|
|
547
|
-
});
|
|
548
|
-
await this.cache?.setExternal("teacherPhotos", photoCacheKey, { blobKey });
|
|
549
|
-
}
|
|
550
|
-
else {
|
|
551
|
-
await this.cache?.set("teacherPhotos", photoCacheKey, {
|
|
552
|
-
data: photoBuffer.toString("base64"),
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
return photoBuffer;
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Get the teacher's photo without parsing the schedule page.
|
|
559
|
-
* Uses the known URL pattern directly — no extra page fetch needed.
|
|
560
|
-
* Returns null if the teacher has no photo.
|
|
561
|
-
*/
|
|
562
|
-
async getTeacherPhotoLazy(teacherId) {
|
|
813
|
+
requirePositiveId(teacherId, "Teacher ID");
|
|
563
814
|
const photoCacheKey = String(teacherId);
|
|
564
815
|
const cachedPhoto = this.cache?.getLocal("teacherPhotos", photoCacheKey) ??
|
|
565
816
|
await this.cache?.get("teacherPhotos", photoCacheKey);
|