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.
Files changed (80) hide show
  1. package/README.md +165 -193
  2. package/dist/browser.d.ts +1 -1
  3. package/dist/browser.js +1 -1
  4. package/dist/common/cache.js +30 -9
  5. package/dist/common/http.d.ts +6 -0
  6. package/dist/common/http.js +8 -1
  7. package/dist/common/parse.d.ts +1 -1
  8. package/dist/common/parse.js +2 -2
  9. package/dist/common/types.d.ts +8 -2
  10. package/dist/common/types.js +12 -0
  11. package/dist/core.d.ts +10 -0
  12. package/dist/core.js +5 -0
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.js +4 -4
  15. package/dist/lk/client.d.ts +8 -6
  16. package/dist/lk/client.js +51 -23
  17. package/dist/lk/types.d.ts +7 -7
  18. package/dist/node.d.ts +1 -0
  19. package/dist/node.js +1 -0
  20. package/dist/parsers.d.ts +3 -0
  21. package/dist/parsers.js +2 -0
  22. package/dist/tt/client.d.ts +55 -63
  23. package/dist/tt/client.js +477 -226
  24. package/dist/tt/domain/directory.d.ts +14 -0
  25. package/dist/tt/domain/directory.js +116 -0
  26. package/dist/tt/domain/ids.d.ts +7 -0
  27. package/dist/tt/domain/ids.js +20 -0
  28. package/dist/tt/domain/index.d.ts +7 -0
  29. package/dist/tt/domain/index.js +5 -0
  30. package/dist/tt/domain/normalize.d.ts +7 -0
  31. package/dist/tt/domain/normalize.js +65 -0
  32. package/dist/tt/domain/repository.d.ts +51 -0
  33. package/dist/tt/domain/repository.js +805 -0
  34. package/dist/tt/domain/schedule.d.ts +37 -0
  35. package/dist/tt/domain/schedule.js +292 -0
  36. package/dist/tt/domain/types.d.ts +175 -0
  37. package/dist/tt/domain/types.js +1 -0
  38. package/dist/tt/observations.d.ts +11 -0
  39. package/dist/tt/observations.js +131 -0
  40. package/dist/tt/parse/audience.d.ts +3 -3
  41. package/dist/tt/parse/audience.js +47 -15
  42. package/dist/tt/parse/entry-parts.d.ts +1 -0
  43. package/dist/tt/parse/entry-parts.js +5 -2
  44. package/dist/tt/parse/full-schedule.d.ts +9 -4
  45. package/dist/tt/parse/full-schedule.js +32 -41
  46. package/dist/tt/parse/groups.d.ts +1 -1
  47. package/dist/tt/parse/groups.js +1 -1
  48. package/dist/tt/parse/index.d.ts +4 -4
  49. package/dist/tt/parse/index.js +4 -4
  50. package/dist/tt/parse/lists.d.ts +6 -5
  51. package/dist/tt/parse/lists.js +7 -2
  52. package/dist/tt/parse/overlays.d.ts +5 -4
  53. package/dist/tt/parse/overlays.js +13 -14
  54. package/dist/tt/parse/teacher.d.ts +2 -3
  55. package/dist/tt/parse/teacher.js +14 -56
  56. package/dist/tt/parse/webinars.js +3 -4
  57. package/dist/tt/types.d.ts +54 -69
  58. package/dist/tt/utils/date.d.ts +4 -0
  59. package/dist/tt/utils/date.js +30 -1
  60. package/dist/tt/utils/index.d.ts +2 -3
  61. package/dist/tt/utils/index.js +2 -3
  62. package/dist/tt/utils/period.d.ts +4 -4
  63. package/dist/tt/utils/period.js +9 -8
  64. package/dist/tt/utils/semester.d.ts +4 -4
  65. package/dist/tt/utils/semester.js +25 -5
  66. package/dist/tt/utils/time-slots.d.ts +3 -5
  67. package/dist/tt/utils/time-slots.js +19 -34
  68. package/dist/tt/webinars.d.ts +7 -0
  69. package/dist/tt/webinars.js +38 -0
  70. package/docs/fixture-review.md +56 -0
  71. package/docs/testing.md +23 -0
  72. package/docs/v5-architecture.md +144 -0
  73. package/docs/v5-migration.md +451 -0
  74. package/package.json +17 -7
  75. package/dist/shared.d.ts +0 -9
  76. package/dist/shared.js +0 -7
  77. package/dist/tt/schedule.d.ts +0 -55
  78. package/dist/tt/schedule.js +0 -233
  79. package/dist/tt/utils/lessons.d.ts +0 -16
  80. 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, parseAudienceButtons, parseAudienceFullSchedule, parseAudienceInfo, parseAudienceName, parseGroupButtons, parseFacultyButtons, parseTeacherButtons, parseFullSchedule, parseTeacherFullSchedule, parseTeacherInfo, parsePeriodFromPage, parseWebinars, } from "./parse/index.js";
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
- 1 /* Period.FallSemester */,
11
- 2 /* Period.WinterSession */,
12
- 3 /* Period.SpringSemester */,
13
- 4 /* Period.SummerSession */,
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
- audiences: ttl,
21
- audienceNames: ttl,
51
+ rooms: ttl,
52
+ roomNames: ttl,
22
53
  teachers: ttl,
23
54
  teacherInfo: ttl,
24
55
  teacherPhotos: ttl,
25
- audienceInfo: ttl,
26
- audienceImages: ttl,
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 TtClient {
67
+ export class TimetableClient {
37
68
  http = new HttpClient();
38
- educationType;
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.educationType = opts?.educationType ?? 1 /* EducationType.HigherEducation */;
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.educationType);
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
- const res = await this.http.get(url);
208
+ let res = await this.http.get(url);
108
209
  if (this.loginMode && this.isSessionExpired(res.body)) {
109
210
  await this.relogin();
110
- return this.http.get(url);
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
- return this.http.getBuffer(url);
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
- const res = await this.http.post(url, data);
236
+ let res = await this.http.post(url, data);
119
237
  if (this.loginMode && this.isSessionExpired(res.body)) {
120
238
  await this.relogin();
121
- return this.http.post(url, data);
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 fetchSchedule(groupId, period, academicYearStartYear) {
150
- const cacheKey = `${groupId}:${period}:${academicYearStartYear}-${academicYearStartYear + 1}`;
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
- return cached;
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 days = parseFullSchedule(body, this.educationType);
157
- await this.cache?.set("schedule", cacheKey, days);
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
- * Get schedule for all periods. The returned Schedule automatically
162
- * routes queries to the correct period based on the date.
163
- */
164
- async getSchedule(groupId) {
165
- const schedules = new Map();
166
- const context = await this.getTimetableContext(`${BASE}/index/grouptt/gr/${groupId}`);
167
- const results = await Promise.all(ALL_PERIODS.map(async (period) => {
168
- const days = await this.fetchSchedule(groupId, period, context.academicYearStartYear);
169
- return { period, days };
170
- }));
171
- for (const { period, days } of results) {
172
- schedules.set(period, days);
173
- }
174
- return new Schedule(groupId, schedules, context.period, this.educationType, undefined, undefined, undefined, context.academicYearStartYear);
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
- * Get schedule for a specific period only.
178
- */
179
- async getScheduleForPeriod(opts) {
180
- const context = await this.getTimetableContext(`${BASE}/index/grouptt/gr/${opts.groupId}`);
181
- const days = await this.fetchSchedule(opts.groupId, opts.period, context.academicYearStartYear);
182
- const schedules = new Map();
183
- schedules.set(opts.period, days);
184
- return new Schedule(opts.groupId, schedules, opts.period, this.educationType, undefined, undefined, undefined, context.academicYearStartYear);
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 getGroupsForFaculty(opts) {
234
- const cacheKey = String(opts.facultyId);
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
- return cached;
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(opts.facultyId),
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 searchGroup(opts) {
247
- const cacheKey = `search:${opts.name}:${this.pertt}`;
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
- return cached;
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: opts.name,
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 searchAudience(opts) {
266
- const cacheKey = `search:${opts.name}:${this.pertt}`;
267
- const cached = await this.cache?.get("audiences", cacheKey);
268
- if (cached)
269
- return cached;
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: opts.name,
485
+ audname: name,
272
486
  findaud: "найти",
273
487
  hfac: "0",
274
488
  pertt: this.pertt,
275
489
  });
276
- const data = parseAudienceButtons(body);
277
- await this.cache?.set("audiences", cacheKey, data);
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 getAudiences() {
503
+ async getRooms() {
289
504
  const cacheKey = `all:${this.pertt}`;
290
- const cached = await this.cache?.get("audiences", cacheKey);
291
- if (cached)
292
- return cached;
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 = parseAudienceButtons(body);
300
- await this.cache?.set("audiences", cacheKey, data);
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 findAudienceByName(opts) {
308
- const q = opts.name.length >= 3 ? opts.name : "%%%";
309
- const list = await this.searchAudience({ name: q });
310
- return list.find((a) => a.name === opts.name) ?? null;
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 getAudienceName(audienceId) {
314
- const cacheKey = String(audienceId);
315
- const cached = await this.cache?.get("audienceNames", cacheKey);
316
- if (cached !== null && cached !== undefined)
317
- return cached;
318
- const cachedInfo = await this.cache?.get("audienceInfo", cacheKey);
319
- if (cachedInfo) {
320
- return cachedInfo.name ?? null;
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 { body } = await this.authGet(`${BASE}/index/audtt/aud/${audienceId}`);
323
- const name = parseAudienceName(body);
324
- const info = parseAudienceInfo(body);
325
- await this.cache?.set("audienceNames", cacheKey, name);
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("audienceInfo", cacheKey, info);
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 getAudienceInfo(audienceId) {
335
- const cached = await this.cache?.get("audienceInfo", String(audienceId));
336
- if (cached)
337
- return cached;
338
- const { body } = await this.authGet(`${BASE}/index/audtt/aud/${audienceId}`);
339
- const info = parseAudienceInfo(body);
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("audienceInfo", String(audienceId), info);
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 fetchAudienceSchedule(audienceId, period, academicYearStartYear) {
345
- const cacheKey = `audience:${audienceId}:${period}:${academicYearStartYear}-${academicYearStartYear + 1}`;
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
- return cached;
349
- const url = `${BASE}/index/audtt/aud/${audienceId}`;
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 days = parseAudienceFullSchedule(body);
352
- await this.cache?.set("schedule", cacheKey, days);
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("audienceInfo", String(audienceId)))) {
355
- const info = parseAudienceInfo(body);
636
+ if (!(await this.cache?.get("roomInfo", String(roomId)))) {
637
+ const info = parseRoomInfo(body);
356
638
  if (info)
357
- await this.cache?.set("audienceInfo", String(audienceId), info);
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 getAudienceSchedule(audienceId) {
362
- const schedules = new Map();
363
- const context = await this.getTimetableContext(`${BASE}/index/audtt/aud/${audienceId}`);
364
- const results = await Promise.all(ALL_PERIODS.map(async (period) => {
365
- const days = await this.fetchAudienceSchedule(audienceId, period, context.academicYearStartYear);
366
- return { period, days };
367
- }));
368
- for (const { period, days } of results) {
369
- schedules.set(period, days);
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("audienceImages", cacheKey, {
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("audienceImages", cacheKey, { data: null });
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("audienceImages", cacheKey, { data: null });
676
+ await this.cache?.set("roomImages", cacheKey, { data: null });
406
677
  return null;
407
678
  }
408
679
  if (this.blobAdapter) {
409
- const blobKey = `tt/audience-images/${cacheKey}`;
410
- this.cache?.setLocal("audienceImages", cacheKey, {
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("audienceImages"),
685
+ ttl: this.cache?.ttl("roomImages"),
415
686
  });
416
- await this.cache?.setExternal("audienceImages", cacheKey, { blobKey });
687
+ await this.cache?.setExternal("roomImages", cacheKey, { blobKey });
417
688
  }
418
689
  else {
419
- await this.cache?.set("audienceImages", cacheKey, {
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 getAudienceImage(audienceId) {
427
- return this.getCachedAudienceImage(`aud:${audienceId}`, async () => (await this.getAudienceInfo(audienceId))?.audImageUrl);
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 getAudienceBlockImage(audienceId) {
431
- return this.getCachedAudienceImage(`block:${audienceId}`, async () => (await this.getAudienceInfo(audienceId))?.blockImageUrl);
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 getAudienceFloorplan(audienceId) {
435
- return this.getCachedAudienceImage(`floor:${audienceId}`, async () => (await this.getAudienceInfo(audienceId))?.floorplanUrl);
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 searchTeacher(opts) {
438
- const cacheKey = `search:${opts.name}:${this.pertt}`;
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
- return cached;
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: opts.name,
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
- return cached;
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
- return cached;
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 days = parseTeacherFullSchedule(body, this.educationType);
470
- await this.cache?.set("schedule", cacheKey, days);
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
- const schedules = new Map();
481
- const context = await this.getTimetableContext(`${BASE}/index/techtt/tech/${teacherId}`);
482
- const results = await Promise.all(ALL_PERIODS.map(async (period) => {
483
- const days = await this.fetchTeacherSchedule(teacherId, period, context.academicYearStartYear);
484
- return { period, days };
485
- }));
486
- for (const { period, days } of results) {
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
- return cached;
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 the teacher's photo as a Buffer.
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
- const photoCacheKey = String(teacherId);
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);