chuvsu-js 5.0.1 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lk/client.js CHANGED
@@ -55,8 +55,7 @@ export class StudentPortalClient {
55
55
  }
56
56
  isBinarySessionExpired(response) {
57
57
  const prefix = response.body.subarray(0, 8_192).toString("utf8");
58
- return this.isSessionExpired(prefix) ||
59
- response.contentType?.toLowerCase().includes("text/html") === true;
58
+ return this.isSessionExpired(prefix);
60
59
  }
61
60
  async authGetBuffer(url) {
62
61
  let res = await this.http.getBufferResponse(url);
package/dist/tt/client.js CHANGED
@@ -191,8 +191,7 @@ export class TimetableClient {
191
191
  }
192
192
  isBinarySessionExpired(response) {
193
193
  const prefix = response.body.subarray(0, 8_192).toString("utf8");
194
- return this.isSessionExpired(prefix) ||
195
- response.contentType?.toLowerCase().includes("text/html") === true;
194
+ return this.isSessionExpired(prefix);
196
195
  }
197
196
  async relogin() {
198
197
  if (!this.loginMode)
@@ -3,5 +3,7 @@ export { TimetableDirectory } from "./directory.js";
3
3
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
4
4
  export { MemoryTimetableRepositoryAdapter, TimetableRepository, } from "./repository.js";
5
5
  export { Schedule } from "./schedule.js";
6
+ export { MaterializedSchedule, materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
6
7
  export type { ScheduleQueryOptions, ScheduleOptions, ScheduleWeekdayOptions, } from "./schedule.js";
8
+ export type { MaterializeScheduleOptions, MaterializedScheduleSnapshot, SerializedLessonOccurrence, SerializedLessonSourceRef, } from "./materialized-schedule.js";
7
9
  export type { GroupAttendance, GroupRef, IngestResult, LessonId, LessonIdGenerator, LessonOccurrence, LessonRecurrence, LessonSeries, LessonSeriesId, RelationCompleteness, RelationSet, LessonSourceRef, LessonStatus, LessonSubstitution, LessonTransfer, NamedEntityRef, OccurrenceObservation, RoomRef, ScheduleObservation, ScheduleOwner, ScheduleSourceSnapshot, SeriesObservation, TeacherRef, TimetableDirectorySnapshot, TimetableRepositoryAdapter, TimetableRepositorySnapshot, } from "./types.js";
@@ -3,3 +3,4 @@ export { TimetableDirectory } from "./directory.js";
3
3
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
4
4
  export { MemoryTimetableRepositoryAdapter, TimetableRepository, } from "./repository.js";
5
5
  export { Schedule } from "./schedule.js";
6
+ export { MaterializedSchedule, materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
@@ -0,0 +1,40 @@
1
+ import type { AcademicPeriod, LocalDate } from "../../common/types.js";
2
+ import type { Schedule } from "./schedule.js";
3
+ import type { LessonOccurrence, LessonSourceRef, ScheduleOwner } from "./types.js";
4
+ export interface SerializedLessonSourceRef extends Omit<LessonSourceRef, "observedAt"> {
5
+ observedAt: string;
6
+ }
7
+ export interface SerializedLessonOccurrence extends Omit<LessonOccurrence, "sources"> {
8
+ sources: SerializedLessonSourceRef[];
9
+ }
10
+ export interface MaterializedScheduleSnapshot {
11
+ schemaVersion: 1;
12
+ repositoryRevision: number;
13
+ owner: ScheduleOwner;
14
+ academicYearStartYear: number;
15
+ period: AcademicPeriod;
16
+ lessonsByDate: Record<LocalDate, SerializedLessonOccurrence[]>;
17
+ }
18
+ export interface MaterializeScheduleOptions {
19
+ start?: Date;
20
+ end?: Date;
21
+ includeSources?: boolean;
22
+ }
23
+ export declare class MaterializedSchedule {
24
+ readonly owner: ScheduleOwner;
25
+ readonly academicYearStartYear: number;
26
+ readonly period: AcademicPeriod;
27
+ readonly repositoryRevision: number;
28
+ private readonly lessonsByDate;
29
+ private readonly dateSetCache;
30
+ constructor(snapshot: MaterializedScheduleSnapshot);
31
+ export(): MaterializedScheduleSnapshot;
32
+ on(date: Date, options?: {
33
+ subgroup?: number;
34
+ }): LessonOccurrence[];
35
+ dateKeys(options?: {
36
+ subgroup?: number;
37
+ }): ReadonlySet<LocalDate>;
38
+ }
39
+ export declare function materializeSchedule(schedule: Schedule, options?: MaterializeScheduleOptions): MaterializedSchedule;
40
+ export declare function materializeScheduleSnapshot(schedule: Schedule, options?: MaterializeScheduleOptions): MaterializedScheduleSnapshot;
@@ -0,0 +1,132 @@
1
+ import { formatLocalDate } from "../utils/index.js";
2
+ import { entityKey } from "./normalize.js";
3
+ function attendanceMatchesSubgroup(groups, owner, subgroup) {
4
+ if (subgroup == null)
5
+ return true;
6
+ if (owner.type === "group") {
7
+ const ownerKey = entityKey(owner.group);
8
+ return groups
9
+ .filter((value) => entityKey(value.group) === ownerKey)
10
+ .some((value) => value.subgroup == null || value.subgroup === subgroup);
11
+ }
12
+ return groups.some((value) => value.subgroup == null || value.subgroup === subgroup);
13
+ }
14
+ function serializeOccurrence(value, includeSources) {
15
+ return {
16
+ ...structuredClone(value),
17
+ sources: includeSources
18
+ ? value.sources.map((source) => ({
19
+ ...structuredClone(source),
20
+ observedAt: source.observedAt.toISOString(),
21
+ }))
22
+ : [],
23
+ };
24
+ }
25
+ function deserializeOccurrence(value) {
26
+ return {
27
+ ...structuredClone(value),
28
+ sources: value.sources.map((source) => ({
29
+ ...structuredClone(source),
30
+ observedAt: new Date(source.observedAt),
31
+ })),
32
+ };
33
+ }
34
+ function sortOccurrences(left, right) {
35
+ return (left.scheduledDate.localeCompare(right.scheduledDate) ||
36
+ ((left.time?.start.hours ?? 24) * 60 +
37
+ (left.time?.start.minutes ?? 0)) -
38
+ ((right.time?.start.hours ?? 24) * 60 +
39
+ (right.time?.start.minutes ?? 0)) ||
40
+ (left.slotNumber ?? Number.MAX_SAFE_INTEGER) -
41
+ (right.slotNumber ?? Number.MAX_SAFE_INTEGER) ||
42
+ left.subject.localeCompare(right.subject) ||
43
+ left.id.localeCompare(right.id));
44
+ }
45
+ export class MaterializedSchedule {
46
+ owner;
47
+ academicYearStartYear;
48
+ period;
49
+ repositoryRevision;
50
+ lessonsByDate = new Map();
51
+ dateSetCache = new Map();
52
+ constructor(snapshot) {
53
+ if (snapshot.schemaVersion !== 1) {
54
+ throw new Error(`Unsupported materialized schedule schema: ${snapshot.schemaVersion}`);
55
+ }
56
+ this.owner = structuredClone(snapshot.owner);
57
+ this.academicYearStartYear = snapshot.academicYearStartYear;
58
+ this.period = snapshot.period;
59
+ this.repositoryRevision = snapshot.repositoryRevision;
60
+ for (const [date, lessons] of Object.entries(snapshot.lessonsByDate)) {
61
+ this.lessonsByDate.set(date, lessons.map(deserializeOccurrence).sort(sortOccurrences));
62
+ }
63
+ }
64
+ export() {
65
+ return {
66
+ schemaVersion: 1,
67
+ repositoryRevision: this.repositoryRevision,
68
+ owner: structuredClone(this.owner),
69
+ academicYearStartYear: this.academicYearStartYear,
70
+ period: this.period,
71
+ lessonsByDate: Object.fromEntries([...this.lessonsByDate].map(([date, lessons]) => [
72
+ date,
73
+ lessons.map((lesson) => serializeOccurrence(lesson, true)),
74
+ ])),
75
+ };
76
+ }
77
+ on(date, options) {
78
+ const lessons = this.lessonsByDate.get(formatLocalDate(date)) ?? [];
79
+ return structuredClone(options?.subgroup == null
80
+ ? lessons
81
+ : lessons.filter((lesson) => attendanceMatchesSubgroup(lesson.groups.values, this.owner, options.subgroup)));
82
+ }
83
+ dateKeys(options) {
84
+ const subgroup = options?.subgroup;
85
+ const cached = this.dateSetCache.get(subgroup);
86
+ if (cached)
87
+ return new Set(cached);
88
+ const dates = new Set();
89
+ for (const [date, lessons] of this.lessonsByDate) {
90
+ if (subgroup == null ||
91
+ lessons.some((lesson) => attendanceMatchesSubgroup(lesson.groups.values, this.owner, subgroup))) {
92
+ dates.add(date);
93
+ }
94
+ }
95
+ this.dateSetCache.set(subgroup, dates);
96
+ return new Set(dates);
97
+ }
98
+ }
99
+ export function materializeSchedule(schedule, options) {
100
+ return new MaterializedSchedule(materializeScheduleSnapshot(schedule, options));
101
+ }
102
+ export function materializeScheduleSnapshot(schedule, options) {
103
+ const start = options?.start == null
104
+ ? new Date(schedule.academicYearStartYear, 8, 1)
105
+ : new Date(options.start);
106
+ const end = options?.end == null
107
+ ? new Date(schedule.academicYearStartYear + 1, 7, 31)
108
+ : new Date(options.end);
109
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
110
+ throw new RangeError("Materialized schedule range contains an invalid date");
111
+ }
112
+ if (start > end) {
113
+ throw new RangeError("Materialized schedule start must not exceed end");
114
+ }
115
+ const lessonsByDate = {};
116
+ const date = new Date(start);
117
+ date.setHours(0, 0, 0, 0);
118
+ for (; date <= end; date.setDate(date.getDate() + 1)) {
119
+ const lessons = schedule.on(date);
120
+ if (lessons.length === 0)
121
+ continue;
122
+ lessonsByDate[formatLocalDate(date)] = lessons.map((lesson) => serializeOccurrence(lesson, options?.includeSources !== false));
123
+ }
124
+ return {
125
+ schemaVersion: 1,
126
+ repositoryRevision: schedule.revision,
127
+ owner: schedule.owner,
128
+ academicYearStartYear: schedule.academicYearStartYear,
129
+ period: schedule.period,
130
+ lessonsByDate,
131
+ };
132
+ }
@@ -1,6 +1,7 @@
1
1
  import { AcademicPeriod } from "../../common/types.js";
2
2
  import { type Holiday, type HolidayTransfer } from "../utils/index.js";
3
3
  import { TimetableRepository } from "./repository.js";
4
+ import { type MaterializeScheduleOptions, type MaterializedSchedule, type MaterializedScheduleSnapshot } from "./materialized-schedule.js";
4
5
  import type { LessonOccurrence, LessonSeries, ScheduleOwner } from "./types.js";
5
6
  export interface ScheduleQueryOptions {
6
7
  subgroup?: number;
@@ -20,10 +21,16 @@ export declare class Schedule {
20
21
  readonly period: AcademicPeriod;
21
22
  readonly holidays: Holiday[];
22
23
  readonly holidayTransfers: HolidayTransfer[];
24
+ private cachedRevision;
25
+ private cachedSeries;
26
+ private cachedDirectOccurrences;
23
27
  constructor(repository: TimetableRepository, owner: ScheduleOwner, academicYearStartYear: number, options?: ScheduleOptions);
24
28
  get revision(): number;
29
+ private queryData;
25
30
  series(): LessonSeries[];
26
31
  on(date: Date, options?: ScheduleQueryOptions): LessonOccurrence[];
32
+ materialize(options?: MaterializeScheduleOptions): MaterializedSchedule;
33
+ materializeSnapshot(options?: MaterializeScheduleOptions): MaterializedScheduleSnapshot;
27
34
  week(week?: number, options?: ScheduleQueryOptions): LessonOccurrence[];
28
35
  weekday(weekday: number, options?: ScheduleWeekdayOptions): LessonOccurrence[];
29
36
  today(options?: ScheduleQueryOptions): LessonOccurrence[];
@@ -2,6 +2,7 @@ import { AcademicPeriod } from "../../common/types.js";
2
2
  import { getAdjacentSemester, getCurrentPeriod, getMonday, getSemesterStart, getSemesterWeeks, getWeekNumber, formatLocalDate, isHoliday, parseLocalDate, RUSSIAN_HOLIDAYS, } from "../utils/index.js";
3
3
  import { occurrenceIdForSeries } from "./ids.js";
4
4
  import { entityKey, normalizeScheduleText } from "./normalize.js";
5
+ import { materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
5
6
  function dateAt(date, time) {
6
7
  const value = new Date(date);
7
8
  value.setHours(time.hours, time.minutes, 0, 0);
@@ -76,6 +77,9 @@ export class Schedule {
76
77
  period;
77
78
  holidays;
78
79
  holidayTransfers;
80
+ cachedRevision = -1;
81
+ cachedSeries = [];
82
+ cachedDirectOccurrences = [];
79
83
  constructor(repository, owner, academicYearStartYear, options) {
80
84
  this.repository = repository;
81
85
  this.owner = structuredClone(owner);
@@ -90,21 +94,32 @@ export class Schedule {
90
94
  get revision() {
91
95
  return this.repository.revision;
92
96
  }
97
+ queryData() {
98
+ if (this.cachedRevision !== this.repository.revision) {
99
+ this.cachedSeries = this.repository.getSeries({
100
+ owner: this.owner,
101
+ academicYearStartYear: this.academicYearStartYear,
102
+ });
103
+ this.cachedDirectOccurrences = this.repository.getDirectOccurrences({
104
+ owner: this.owner,
105
+ academicYearStartYear: this.academicYearStartYear,
106
+ });
107
+ this.cachedRevision = this.repository.revision;
108
+ }
109
+ return {
110
+ series: this.cachedSeries,
111
+ direct: this.cachedDirectOccurrences,
112
+ };
113
+ }
93
114
  series() {
94
- return this.repository.getSeries({
95
- owner: this.owner,
96
- academicYearStartYear: this.academicYearStartYear,
97
- });
115
+ return structuredClone(this.queryData().series);
98
116
  }
99
117
  on(date, options) {
100
118
  if (!this.isInAcademicYear(date))
101
119
  return [];
102
120
  const holiday = isHoliday(date, this.holidays, this.holidayTransfers);
103
121
  const localDate = formatLocalDate(date);
104
- const direct = this.repository.getDirectOccurrences({
105
- owner: this.owner,
106
- academicYearStartYear: this.academicYearStartYear,
107
- });
122
+ const { series: recurringSeries, direct } = this.queryData();
108
123
  const movedSources = direct
109
124
  .filter((value) => value.movedFrom)
110
125
  .map((value) => ({
@@ -114,7 +129,7 @@ export class Schedule {
114
129
  groups: value.groups.values,
115
130
  }));
116
131
  const occurrences = [];
117
- for (const series of this.series()) {
132
+ for (const series of recurringSeries) {
118
133
  if (holiday)
119
134
  continue;
120
135
  const week = recurrenceIncludes(series, date);
@@ -168,7 +183,7 @@ export class Schedule {
168
183
  };
169
184
  if (occurrenceMatchesOwner(occurrence, this.owner) &&
170
185
  attendanceMatchesSubgroup(occurrence.groups.values, this.owner, options?.subgroup)) {
171
- occurrences.push(occurrence);
186
+ occurrences.push(structuredClone(occurrence));
172
187
  }
173
188
  }
174
189
  for (const occurrence of direct) {
@@ -179,10 +194,16 @@ export class Schedule {
179
194
  if (!attendanceMatchesSubgroup(occurrence.groups.values, this.owner, options?.subgroup)) {
180
195
  continue;
181
196
  }
182
- occurrences.push(occurrence);
197
+ occurrences.push(structuredClone(occurrence));
183
198
  }
184
199
  return occurrences.sort(sortOccurrences);
185
200
  }
201
+ materialize(options) {
202
+ return materializeSchedule(this, options);
203
+ }
204
+ materializeSnapshot(options) {
205
+ return materializeScheduleSnapshot(this, options);
206
+ }
186
207
  week(week, options) {
187
208
  const period = this.period === AcademicPeriod.WinterSession ||
188
209
  this.period === AcademicPeriod.SummerSession
@@ -276,6 +276,31 @@ const client = new TimetableClient({
276
276
  const snapshot = await client.exportRepository();
277
277
  ```
278
278
 
279
+ Репозиторий предназначен для серверного хранения и дополнения данных. Не
280
+ отправляйте полный `TimetableRepositorySnapshot` в браузер: его размер растет
281
+ вместе со всеми когда-либо загруженными расписаниями, а восстановление требует
282
+ повторной агрегации связей.
283
+
284
+ Для API, SSR и офлайн-кеша материализуйте только готовое расписание владельца:
285
+
286
+ ```ts
287
+ // Сервер: агрегация выполняется один раз, источники можно убрать из payload.
288
+ const payload = schedule.materializeSnapshot({ includeSources: false });
289
+ return Response.json(payload);
290
+
291
+ // Браузер: JSON восстанавливается в индексированное по дате представление.
292
+ import { MaterializedSchedule } from "chuvsu-js/browser";
293
+
294
+ const schedule = new MaterializedSchedule(await response.json());
295
+ schedule.on(date); // O(1) поиск даты
296
+ schedule.dateKeys({ subgroup: 2 }); // даты для календаря
297
+ ```
298
+
299
+ `MaterializedScheduleSnapshot` содержит уже объединенные `LessonOccurrence`,
300
+ но не канонический репозиторий. Поле `repositoryRevision` позволяет серверному
301
+ кешу инвалидировать снимок после дополнения пары из расписания преподавателя,
302
+ аудитории или другой группы.
303
+
279
304
  Не передавайте экспорт `exportCache()` из v4 в `importCache()` v5: изменились
280
305
  ключи, категории и форма закешированных страниц. Старый TTL-кеш и старые blob-
281
306
  ключи следует удалить. Снимок репозитория v5 имеет `schemaVersion: 5`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "5.0.1",
3
+ "version": "5.1.1",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",