chuvsu-js 5.0.0 → 5.1.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.
@@ -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
@@ -1,8 +1,9 @@
1
1
  import { parseTeacher, text } from "../../common/parse.js";
2
2
  import { parseGroupsString } from "./groups.js";
3
- import { hasDistanceMarker } from "./entry-parts.js";
4
- import { LESSON_TYPE_PATTERN, LESSON_TYPE_RE, LESSON_TYPE_RE_I, SUBGROUP_RE, } from "./patterns.js";
3
+ import { containsGroupCode, hasDistanceMarker, linesAfterSubject, stripDistanceMarker, } from "./entry-parts.js";
4
+ import { LESSON_TYPE_RE, LESSON_TYPE_RE_I, SUBGROUP_RE, } from "./patterns.js";
5
5
  const GROUP_CODE_RE = /[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+/u;
6
+ const ROOM_CODE_RE = /^[А-Яа-яA-Za-z]-\d+$/u;
6
7
  const BLUE_SPAN_RE = /<span\b(?=[^>]*(?:style=["'][^"']*color:\s*blue|class=["'][^"']*\bblue\b))[^>]*>([^<]+)<\/span>/i;
7
8
  export function parseDate(dd, mm, yyyy) {
8
9
  return `${yyyy}-${mm}-${dd}`;
@@ -94,8 +95,9 @@ export function parseSubstituteForDiv(div) {
94
95
  const date = parseDate(m[1], m[2], m[3]);
95
96
  // Original teacher: first blue span (right after "замена вместо:")
96
97
  const origTeacherMatch = divHtml.match(/замена\s*вместо:\s*<\/b><\/span>\s*<span[^>]*>([^<]+)<\/span>/);
97
- const originalTeacher = origTeacherMatch
98
- ? parseTeacher(origTeacherMatch[1].trim())
98
+ const originalValue = origTeacherMatch?.[1].trim();
99
+ const originalTeacher = originalValue && !ROOM_CODE_RE.test(originalValue)
100
+ ? parseTeacher(originalValue)
99
101
  : undefined;
100
102
  // Subject: second blue span
101
103
  const subjectEl = div.querySelectorAll('span[style*="color: blue"]');
@@ -111,14 +113,20 @@ export function parseSubstituteForDiv(div) {
111
113
  return null;
112
114
  const roomMatch = divHtml.match(/(?:<br\s*\/?>)\s*([А-Яа-яA-Za-z]-\d+)/);
113
115
  const typeMatch = divText.match(LESSON_TYPE_RE);
114
- const groupsMatch = divHtml.match(new RegExp(`\\((?:${LESSON_TYPE_PATTERN})\\)\\s*(?:<br\\s*\\/?>)\\s*([^<]+?)(?:\\s*<i|$)`));
116
+ const parts = linesAfterSubject(divHtml, subject);
117
+ const teacherLine = stripDistanceMarker(parts.find((line) => !containsGroupCode(stripDistanceMarker(line))) ?? "");
118
+ const groupsLine = parts
119
+ .filter((line) => containsGroupCode(stripDistanceMarker(line)))
120
+ .map(stripDistanceMarker)
121
+ .join(" ");
115
122
  const subgroupMatch = divText.match(SUBGROUP_RE);
116
- const groups = parseGroupsString(groupsMatch?.[1]);
123
+ const groups = parseGroupsString(groupsLine);
117
124
  return {
118
125
  entry: {
119
126
  room: roomMatch?.[1] || undefined,
120
127
  subject,
121
128
  type: typeMatch?.[1] ?? "",
129
+ teacher: teacherLine ? parseTeacher(teacherLine) : undefined,
122
130
  groups: groups.length > 0 ? groups : undefined,
123
131
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
124
132
  isDistance: hasDistanceMarker(divText) || hasDistanceMarker(roomMatch?.[1] ?? ""),
@@ -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.0",
3
+ "version": "5.1.0",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",