chuvsu-js 5.2.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # chuvsu-js
2
2
 
3
+ > [!WARNING]
4
+ > Ооочень плохой вайбкод, библиотека будет переделана и не раз. Есть очень много пространства для упрощения использования и сохранения крутой архитектуры.
5
+
3
6
  Типизированная библиотека для сервисов ЧГУ им. И. Н. Ульянова:
4
7
 
5
8
  - `tt.chuvsu.ru` — расписания групп, преподавателей, аудиторий и вебинары;
@@ -5,3 +5,5 @@ export declare class RandomLessonIdGenerator implements LessonIdGenerator {
5
5
  }
6
6
  /** Stable occurrence identity inside a persisted series. */
7
7
  export declare function occurrenceIdForSeries(seriesId: LessonSeriesId, academicWeek: number, ordinal?: number): LessonId;
8
+ /** Convert persisted v5 UUID IDs without changing their identity. */
9
+ export declare function migrateLegacyLessonId(id: string): string;
@@ -1,20 +1,50 @@
1
- function randomId(prefix) {
1
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
2
+ const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3
+ function encodeId(bytes) {
4
+ let encoded = "";
5
+ for (let index = 0; index < bytes.length; index += 3) {
6
+ const block = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
7
+ encoded += ID_ALPHABET[(block >>> 18) & 63];
8
+ encoded += ID_ALPHABET[(block >>> 12) & 63];
9
+ encoded += ID_ALPHABET[(block >>> 6) & 63];
10
+ encoded += ID_ALPHABET[block & 63];
11
+ }
12
+ return encoded;
13
+ }
14
+ function encodeLegacyUuid(uuid) {
15
+ const hex = uuid.replaceAll("-", "").slice(0, 18);
16
+ const bytes = new Uint8Array(hex.match(/.{2}/gu).map((pair) => Number.parseInt(pair, 16)));
17
+ return encodeId(bytes);
18
+ }
19
+ function randomId() {
2
20
  const cryptoApi = globalThis.crypto;
3
- if (cryptoApi?.randomUUID)
4
- return `${prefix}_${cryptoApi.randomUUID()}`;
5
- const random = Math.random().toString(36).slice(2);
6
- const time = Date.now().toString(36);
7
- return `${prefix}_${time}_${random}`;
21
+ if (!cryptoApi?.getRandomValues)
22
+ throw new Error("Secure random IDs require Web Crypto");
23
+ return encodeId(cryptoApi.getRandomValues(new Uint8Array(9)));
8
24
  }
9
25
  export class RandomLessonIdGenerator {
10
26
  seriesId() {
11
- return randomId("ser");
27
+ return randomId();
12
28
  }
13
29
  lessonId() {
14
- return randomId("les");
30
+ return randomId();
15
31
  }
16
32
  }
17
33
  /** Stable occurrence identity inside a persisted series. */
18
34
  export function occurrenceIdForSeries(seriesId, academicWeek, ordinal = 0) {
19
- return `les_${seriesId}_${academicWeek}_${ordinal}`;
35
+ return `${seriesId}.${academicWeek}.${ordinal}`;
36
+ }
37
+ /** Convert persisted v5 UUID IDs without changing their identity. */
38
+ export function migrateLegacyLessonId(id) {
39
+ const occurrence = /^les_ser_([0-9a-f-]{36})_(\d+)_(\d+)$/iu.exec(id);
40
+ if (occurrence && UUID_PATTERN.test(occurrence[1])) {
41
+ return occurrenceIdForSeries(encodeLegacyUuid(occurrence[1]), Number(occurrence[2]), Number(occurrence[3]));
42
+ }
43
+ const series = /^ser_([0-9a-f-]{36})$/iu.exec(id);
44
+ if (series && UUID_PATTERN.test(series[1]))
45
+ return encodeLegacyUuid(series[1]);
46
+ const direct = /^les_([0-9a-f-]{36})$/iu.exec(id);
47
+ if (direct && UUID_PATTERN.test(direct[1]))
48
+ return encodeLegacyUuid(direct[1]);
49
+ return id;
20
50
  }
@@ -1,9 +1,9 @@
1
- export { RandomLessonIdGenerator, occurrenceIdForSeries } from "./ids.js";
1
+ export { RandomLessonIdGenerator, occurrenceIdForSeries, migrateLegacyLessonId } from "./ids.js";
2
+ export { ScheduleSnapshot, createScheduleSnapshot } from "./schedule-snapshot.js";
3
+ export type { SerializedScheduleSnapshot } from "./schedule-snapshot.js";
2
4
  export { TimetableDirectory } from "./directory.js";
3
5
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
4
6
  export { MemoryTimetableRepositoryAdapter, TimetableRepository, } from "./repository.js";
5
7
  export { Schedule } from "./schedule.js";
6
- export { MaterializedSchedule, materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
7
8
  export type { ScheduleQueryOptions, ScheduleOptions, ScheduleWeekdayOptions, } from "./schedule.js";
8
- export type { MaterializeScheduleOptions, MaterializedScheduleSnapshot, SerializedLessonOccurrence, SerializedLessonSourceRef, } from "./materialized-schedule.js";
9
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, TimetableRepositoryPatch, TimetableRepositorySnapshot, } from "./types.js";
@@ -1,6 +1,6 @@
1
- export { RandomLessonIdGenerator, occurrenceIdForSeries } from "./ids.js";
1
+ export { RandomLessonIdGenerator, occurrenceIdForSeries, migrateLegacyLessonId } from "./ids.js";
2
+ export { ScheduleSnapshot, createScheduleSnapshot } from "./schedule-snapshot.js";
2
3
  export { TimetableDirectory } from "./directory.js";
3
4
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
4
5
  export { MemoryTimetableRepositoryAdapter, TimetableRepository, } from "./repository.js";
5
6
  export { Schedule } from "./schedule.js";
6
- export { MaterializedSchedule, materializeSchedule, materializeScheduleSnapshot, } from "./materialized-schedule.js";
@@ -597,9 +597,6 @@ export class TimetableRepository {
597
597
  if (!observation.subject.trim()) {
598
598
  throw new Error(`Empty lesson subject in ${source.sourceKey}/${observation.key}`);
599
599
  }
600
- if (!observation.type.trim()) {
601
- throw new Error(`Empty lesson type in ${source.sourceKey}/${observation.key}`);
602
- }
603
600
  const relations = [
604
601
  ["group", observation.groups, observation.groups.values.map((value) => value.group)],
605
602
  ["teacher", observation.teachers, observation.teachers.values],
@@ -0,0 +1,51 @@
1
+ import type { AcademicPeriod, LocalDate } from "../../common/types.js";
2
+ import { type Holiday } from "../utils/index.js";
3
+ import { Schedule, type ScheduleQueryOptions } from "./schedule.js";
4
+ import type { LessonOccurrence, LessonSeries, LessonSourceRef, ScheduleOwner } from "./types.js";
5
+ type SerializedSource = Omit<LessonSourceRef, "observedAt"> & {
6
+ observedAt: string;
7
+ };
8
+ type SerializedSeries = Omit<LessonSeries, "sources"> & {
9
+ sources: SerializedSource[];
10
+ };
11
+ type SerializedOccurrence = Omit<LessonOccurrence, "sources"> & {
12
+ sources: SerializedSource[];
13
+ };
14
+ export interface SerializedScheduleSnapshot {
15
+ schemaVersion: 2;
16
+ repositoryRevision: number;
17
+ owner: ScheduleOwner;
18
+ academicYearStartYear: number;
19
+ period: AcademicPeriod;
20
+ start: LocalDate;
21
+ end: LocalDate;
22
+ series: SerializedSeries[];
23
+ direct: SerializedOccurrence[];
24
+ holidays: Holiday[];
25
+ holidayTransfers: {
26
+ dayOff: string;
27
+ workDay: string | null;
28
+ }[];
29
+ }
30
+ /** Browser-ready schedule backed by recurring rules and dated exceptions. */
31
+ export declare class ScheduleSnapshot extends Schedule {
32
+ private readonly snapshot;
33
+ private readonly snapshotSeries;
34
+ private readonly snapshotDirect;
35
+ private readonly dateSetCache;
36
+ constructor(snapshot: SerializedScheduleSnapshot);
37
+ get revision(): number;
38
+ protected queryData(): {
39
+ series: LessonSeries[];
40
+ direct: LessonOccurrence[];
41
+ };
42
+ export(): SerializedScheduleSnapshot;
43
+ on(date: Date, options?: ScheduleQueryOptions): LessonOccurrence[];
44
+ dateKeys(options?: ScheduleQueryOptions): ReadonlySet<LocalDate>;
45
+ }
46
+ export declare function createScheduleSnapshot(schedule: Schedule, options?: {
47
+ start?: Date;
48
+ end?: Date;
49
+ includeSources?: boolean;
50
+ }): SerializedScheduleSnapshot;
51
+ export {};
@@ -0,0 +1,95 @@
1
+ import { formatLocalDate, parseLocalDate } from "../utils/index.js";
2
+ import { Schedule } from "./schedule.js";
3
+ function serializeSources(sources, include) {
4
+ return include
5
+ ? sources.map((source) => ({ ...structuredClone(source), observedAt: source.observedAt.toISOString() }))
6
+ : [];
7
+ }
8
+ function deserializeSources(sources) {
9
+ return sources.map((source) => ({ ...structuredClone(source), observedAt: new Date(source.observedAt) }));
10
+ }
11
+ /** Browser-ready schedule backed by recurring rules and dated exceptions. */
12
+ export class ScheduleSnapshot extends Schedule {
13
+ snapshot;
14
+ snapshotSeries;
15
+ snapshotDirect;
16
+ dateSetCache = new Map();
17
+ constructor(snapshot) {
18
+ if (snapshot.schemaVersion !== 2) {
19
+ throw new Error(`Unsupported schedule snapshot schema: ${snapshot.schemaVersion}`);
20
+ }
21
+ const series = snapshot.series.map((value) => ({
22
+ ...structuredClone(value), sources: deserializeSources(value.sources),
23
+ }));
24
+ const direct = snapshot.direct.map((value) => ({
25
+ ...structuredClone(value), sources: deserializeSources(value.sources),
26
+ }));
27
+ const options = {
28
+ period: snapshot.period,
29
+ holidays: snapshot.holidays,
30
+ holidayTransfers: snapshot.holidayTransfers.map((value) => ({
31
+ dayOff: new Date(value.dayOff),
32
+ workDay: value.workDay ? new Date(value.workDay) : null,
33
+ })),
34
+ };
35
+ super(undefined, snapshot.owner, snapshot.academicYearStartYear, options);
36
+ this.snapshotSeries = series;
37
+ this.snapshotDirect = direct;
38
+ this.snapshot = structuredClone(snapshot);
39
+ }
40
+ get revision() { return this.snapshot.repositoryRevision; }
41
+ queryData() {
42
+ return { series: this.snapshotSeries, direct: this.snapshotDirect };
43
+ }
44
+ export() { return structuredClone(this.snapshot); }
45
+ on(date, options) {
46
+ const key = formatLocalDate(date);
47
+ return key < this.snapshot.start || key > this.snapshot.end ? [] : super.on(date, options);
48
+ }
49
+ dateKeys(options) {
50
+ const subgroup = options?.subgroup;
51
+ const cached = this.dateSetCache.get(subgroup);
52
+ if (cached)
53
+ return new Set(cached);
54
+ const dates = new Set();
55
+ const end = parseLocalDate(this.snapshot.end);
56
+ for (const date = parseLocalDate(this.snapshot.start); date <= end; date.setDate(date.getDate() + 1)) {
57
+ if (this.on(date, options).length)
58
+ dates.add(formatLocalDate(date));
59
+ }
60
+ this.dateSetCache.set(subgroup, dates);
61
+ return new Set(dates);
62
+ }
63
+ }
64
+ export function createScheduleSnapshot(schedule, options) {
65
+ const start = options?.start == null ? new Date(schedule.academicYearStartYear, 8, 1) : new Date(options.start);
66
+ const end = options?.end == null ? new Date(schedule.academicYearStartYear + 1, 7, 31) : new Date(options.end);
67
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
68
+ throw new RangeError("Schedule snapshot range contains an invalid date");
69
+ }
70
+ if (start > end)
71
+ throw new RangeError("Schedule snapshot start must not exceed end");
72
+ const includeSources = options?.includeSources !== false;
73
+ const series = schedule.series().map((value) => ({
74
+ ...value, sources: serializeSources(value.sources, includeSources),
75
+ }));
76
+ const direct = schedule.directOccurrences().map((value) => ({
77
+ ...value, sources: serializeSources(value.sources, includeSources),
78
+ }));
79
+ return {
80
+ schemaVersion: 2,
81
+ repositoryRevision: schedule.revision,
82
+ owner: structuredClone(schedule.owner),
83
+ academicYearStartYear: schedule.academicYearStartYear,
84
+ period: schedule.period,
85
+ start: formatLocalDate(start),
86
+ end: formatLocalDate(end),
87
+ series,
88
+ direct,
89
+ holidays: structuredClone(schedule.holidays),
90
+ holidayTransfers: schedule.holidayTransfers.map((value) => ({
91
+ dayOff: value.dayOff.toISOString(),
92
+ workDay: value.workDay?.toISOString() ?? null,
93
+ })),
94
+ };
95
+ }
@@ -1,7 +1,6 @@
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";
5
4
  import type { LessonOccurrence, LessonSeries, ScheduleOwner } from "./types.js";
6
5
  export interface ScheduleQueryOptions {
7
6
  subgroup?: number;
@@ -15,7 +14,7 @@ export interface ScheduleOptions {
15
14
  holidayTransfers?: HolidayTransfer[];
16
15
  }
17
16
  export declare class Schedule {
18
- readonly repository: TimetableRepository;
17
+ readonly repository?: TimetableRepository;
19
18
  readonly owner: ScheduleOwner;
20
19
  readonly academicYearStartYear: number;
21
20
  readonly period: AcademicPeriod;
@@ -24,13 +23,15 @@ export declare class Schedule {
24
23
  private cachedRevision;
25
24
  private cachedSeries;
26
25
  private cachedDirectOccurrences;
27
- constructor(repository: TimetableRepository, owner: ScheduleOwner, academicYearStartYear: number, options?: ScheduleOptions);
26
+ constructor(repository: TimetableRepository | undefined, owner: ScheduleOwner, academicYearStartYear: number, options?: ScheduleOptions);
28
27
  get revision(): number;
29
- private queryData;
28
+ protected queryData(): {
29
+ series: LessonSeries[];
30
+ direct: LessonOccurrence[];
31
+ };
30
32
  series(): LessonSeries[];
33
+ directOccurrences(): LessonOccurrence[];
31
34
  on(date: Date, options?: ScheduleQueryOptions): LessonOccurrence[];
32
- materialize(options?: MaterializeScheduleOptions): MaterializedSchedule;
33
- materializeSnapshot(options?: MaterializeScheduleOptions): MaterializedScheduleSnapshot;
34
35
  week(week?: number, options?: ScheduleQueryOptions): LessonOccurrence[];
35
36
  weekday(weekday: number, options?: ScheduleWeekdayOptions): LessonOccurrence[];
36
37
  today(options?: ScheduleQueryOptions): LessonOccurrence[];
@@ -2,7 +2,6 @@ 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";
6
5
  function dateAt(date, time) {
7
6
  const value = new Date(date);
8
7
  value.setHours(time.hours, time.minutes, 0, 0);
@@ -92,19 +91,22 @@ export class Schedule {
92
91
  }));
93
92
  }
94
93
  get revision() {
95
- return this.repository.revision;
94
+ return this.repository?.revision ?? 0;
96
95
  }
97
96
  queryData() {
98
- if (this.cachedRevision !== this.repository.revision) {
99
- this.cachedSeries = this.repository.getSeries({
97
+ const repository = this.repository;
98
+ if (!repository)
99
+ throw new Error("Schedule has no live repository");
100
+ if (this.cachedRevision !== repository.revision) {
101
+ this.cachedSeries = repository.getSeries({
100
102
  owner: this.owner,
101
103
  academicYearStartYear: this.academicYearStartYear,
102
104
  });
103
- this.cachedDirectOccurrences = this.repository.getDirectOccurrences({
105
+ this.cachedDirectOccurrences = repository.getDirectOccurrences({
104
106
  owner: this.owner,
105
107
  academicYearStartYear: this.academicYearStartYear,
106
108
  });
107
- this.cachedRevision = this.repository.revision;
109
+ this.cachedRevision = repository.revision;
108
110
  }
109
111
  return {
110
112
  series: this.cachedSeries,
@@ -114,6 +116,9 @@ export class Schedule {
114
116
  series() {
115
117
  return structuredClone(this.queryData().series);
116
118
  }
119
+ directOccurrences() {
120
+ return structuredClone(this.queryData().direct);
121
+ }
117
122
  on(date, options) {
118
123
  if (!this.isInAcademicYear(date))
119
124
  return [];
@@ -198,12 +203,6 @@ export class Schedule {
198
203
  }
199
204
  return occurrences.sort(sortOccurrences);
200
205
  }
201
- materialize(options) {
202
- return materializeSchedule(this, options);
203
- }
204
- materializeSnapshot(options) {
205
- return materializeScheduleSnapshot(this, options);
206
- }
207
206
  week(week, options) {
208
207
  const period = this.period === AcademicPeriod.WinterSession ||
209
208
  this.period === AcademicPeriod.SummerSession
@@ -62,6 +62,7 @@ export interface LessonSeries {
62
62
  academicYearStartYear: number;
63
63
  period: AcademicPeriod;
64
64
  subject: string;
65
+ /** Free-form portal label; empty when no source exposes one. */
65
66
  type: string;
66
67
  slotNumber?: number;
67
68
  time?: TimeRange;
@@ -84,6 +85,7 @@ export interface LessonOccurrence {
84
85
  nominalDate: LocalDate;
85
86
  scheduledDate: LocalDate;
86
87
  subject: string;
88
+ /** Free-form portal label; empty when no source exposes one. */
87
89
  type: string;
88
90
  slotNumber?: number;
89
91
  time?: TimeRange;
@@ -105,6 +107,7 @@ interface ObservationBase {
105
107
  /** Stable only inside one source page projection. */
106
108
  key: string;
107
109
  subject: string;
110
+ /** Free-form portal label; empty when this source does not expose one. */
108
111
  type: string;
109
112
  slotNumber?: number;
110
113
  time?: TimeRange;
@@ -2,8 +2,8 @@ import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text,
2
2
  import { parseSemesterScheduleWith, parseSessionScheduleWith, } from "./full-schedule.js";
3
3
  import { parseGroupsString } from "./groups.js";
4
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
5
- import { LESSON_TYPE_GLOBAL_RE, LESSON_TYPE_RE, FLEXIBLE_LESSON_TYPE_RE_I, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
6
- import { containsGroupCode, hasDistanceMarker, linesAfterSubject, stripDistanceMarker, } from "./entry-parts.js";
5
+ import { LESSON_TYPE_GLOBAL_RE, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
6
+ import { containsGroupCode, hasDistanceMarker, linesAfterSubject, parseLessonTypeAfterSubject, stripDistanceMarker, } from "./entry-parts.js";
7
7
  export function parseRoomInfo(html) {
8
8
  const doc = parseHtml(html);
9
9
  // Name: <span class="htext"><nobr>Аудитория <span style="color: blue;">NAME</span></nobr></span>
@@ -104,10 +104,11 @@ function parseRoomSemesterEntry(el) {
104
104
  cleanText = cleanText.replace(text(div), "");
105
105
  }
106
106
  const subjectEl = td.querySelector('span[style*="color: blue"]');
107
- const subject = subjectEl ? text(subjectEl) : "";
107
+ if (!subjectEl)
108
+ return null;
109
+ const subject = text(subjectEl);
108
110
  if (!subject)
109
111
  return null;
110
- const typeMatch = cleanText.match(LESSON_TYPE_RE);
111
112
  const weeksMatch = cleanText.match(WEEKS_RE);
112
113
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
113
114
  const weekParity = parseWeekParity(cleanHtml);
@@ -127,7 +128,7 @@ function parseRoomSemesterEntry(el) {
127
128
  const groups = parseGroupsString(groupsLine);
128
129
  return {
129
130
  subject,
130
- type: typeMatch?.[1] ?? "",
131
+ type: parseLessonTypeAfterSubject(subjectEl),
131
132
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
132
133
  teacher: teacherLine ? parseTeacher(teacherLine) : undefined,
133
134
  groups: groups.length > 0 ? groups : undefined,
@@ -149,13 +150,14 @@ function parseRoomSessionEntry(td) {
149
150
  const fullHtml = td.innerHTML ?? "";
150
151
  const plainText = text(td);
151
152
  const subjectEl = td.querySelector('span[style*="color: blue"]');
152
- const subject = subjectEl ? text(subjectEl) : "";
153
+ if (!subjectEl)
154
+ return null;
155
+ const subject = text(subjectEl);
153
156
  if (!plainText || !subject)
154
157
  return null;
155
158
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
156
159
  if (!timeMatch)
157
160
  return null;
158
- const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
159
161
  const parts = linesAfterSubject(fullHtml, subject);
160
162
  const teacherLine = parts.find((part) => !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
161
163
  !containsGroupCode(stripDistanceMarker(part)));
@@ -165,7 +167,7 @@ function parseRoomSessionEntry(td) {
165
167
  return {
166
168
  lesson: {
167
169
  subject,
168
- type: typeMatch?.[1].replace(/\.$/, "").toLowerCase() ?? "",
170
+ type: parseLessonTypeAfterSubject(subjectEl).toLowerCase(),
169
171
  teacher: teacherLine ? parseTeacher(stripDistanceMarker(teacherLine)) : undefined,
170
172
  groups: groups.length > 0 ? groups : undefined,
171
173
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
@@ -6,3 +6,8 @@ export declare function linesAfterSubject(html: string, subject: string): string
6
6
  export declare function parseEntryRoom(html: string, subject: string): string;
7
7
  export declare function stripDistanceMarker(value: string): string;
8
8
  export declare function containsGroupCode(value: string): boolean;
9
+ /**
10
+ * Lesson type is free-form portal data. Its stable marker is position: the first
11
+ * parenthesized value immediately after the blue subject span.
12
+ */
13
+ export declare function parseLessonTypeAfterSubject(subjectElement: Element): string;
@@ -2,6 +2,9 @@ const BR_RE = /<br\s*\/?>/i;
2
2
  const TAG_RE = /<[^>]*>/g;
3
3
  const DISTANCE_RE = /(?:^|[^\p{L}\p{N}])(?:дистанционно|ДОТ)(?=$|[^\p{L}\p{N}])/iu;
4
4
  const GROUP_TOKEN_RE = /^[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+$/u;
5
+ const LEADING_PARENTHESIZED_RE = /^\s*\(([^()]*)\)/u;
6
+ const WEEK_METADATA_RE = /(?:^|\s)нед\.?(?:\s|$)/iu;
7
+ const SUBGROUP_METADATA_RE = /^\d+\s*подгруппа$/iu;
5
8
  export function entryHtmlLines(html) {
6
9
  return html.split(BR_RE).map((line) => line.trim());
7
10
  }
@@ -50,3 +53,25 @@ export function stripDistanceMarker(value) {
50
53
  export function containsGroupCode(value) {
51
54
  return value.split(/\s+/).some((token) => GROUP_TOKEN_RE.test(token));
52
55
  }
56
+ /**
57
+ * Lesson type is free-form portal data. Its stable marker is position: the first
58
+ * parenthesized value immediately after the blue subject span.
59
+ */
60
+ export function parseLessonTypeAfterSubject(subjectElement) {
61
+ let suffix = "";
62
+ for (let sibling = subjectElement.nextSibling; sibling; sibling = sibling.nextSibling) {
63
+ if (sibling.tagName?.toLowerCase() === "br")
64
+ break;
65
+ suffix += sibling.textContent ?? "";
66
+ }
67
+ const candidate = suffix.match(LEADING_PARENTHESIZED_RE)?.[1]
68
+ ?.trim()
69
+ .replace(/\.$/, "") ?? "";
70
+ if (!candidate ||
71
+ WEEK_METADATA_RE.test(candidate) ||
72
+ SUBGROUP_METADATA_RE.test(candidate) ||
73
+ candidate.toUpperCase() === "ДОТ") {
74
+ return "";
75
+ }
76
+ return candidate;
77
+ }
@@ -1,7 +1,7 @@
1
1
  import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
2
  import { parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
3
- import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
4
- import { hasDistanceMarker, containsGroupCode, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
3
+ import { SUBGROUP_RE, WEEKS_RE } from "./patterns.js";
4
+ import { hasDistanceMarker, containsGroupCode, linesAfterSubject, parseLessonTypeAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
5
5
  export function parseGroupSchedule(html) {
6
6
  const doc = parseHtml(html);
7
7
  // Session layout has date-based cells with ids like "trd20251224"
@@ -99,10 +99,11 @@ function parseSemesterEntry(el) {
99
99
  cleanText = cleanText.replace(text(div), "");
100
100
  }
101
101
  const subjectEl = td.querySelector('span[style*="color: blue"]');
102
- const subject = subjectEl ? text(subjectEl) : "";
102
+ if (!subjectEl)
103
+ return null;
104
+ const subject = text(subjectEl);
103
105
  if (!subject)
104
106
  return null;
105
- const typeMatch = cleanText.match(LESSON_TYPE_RE);
106
107
  const weeksMatch = cleanText.match(WEEKS_RE);
107
108
  const room = parseEntryRoom(cleanHtml, subject);
108
109
  const teacherLine = linesAfterSubject(cleanHtml, subject).find((line) => {
@@ -114,7 +115,7 @@ function parseSemesterEntry(el) {
114
115
  return {
115
116
  room: room || undefined,
116
117
  subject,
117
- type: typeMatch?.[1] ?? "",
118
+ type: parseLessonTypeAfterSubject(subjectEl),
118
119
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
119
120
  teacher: teacherLine ? parseTeacher(teacherLine) : undefined,
120
121
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
@@ -170,13 +171,14 @@ function parseSessionEntry(td) {
170
171
  return null;
171
172
  const possibleChanges = (td.getAttribute("class") ?? "").includes("want") || undefined;
172
173
  const subjectEl = td.querySelector('span[style*="color: blue"]');
173
- const subject = subjectEl ? text(subjectEl) : "";
174
+ if (!subjectEl)
175
+ return null;
176
+ const subject = text(subjectEl);
174
177
  if (!subject)
175
178
  return null;
176
179
  const room = parseEntryRoom(fullHtml, subject);
177
180
  // Type: parenthesized text after </span>, case-insensitive
178
- const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
179
- const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
181
+ const type = parseLessonTypeAfterSubject(subjectEl).toLowerCase();
180
182
  const subgroupMatch = plainText.match(SUBGROUP_RE);
181
183
  const parts = linesAfterSubject(fullHtml, subject);
182
184
  const teacherPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
@@ -1,5 +1,5 @@
1
- export declare const LESSON_TYPE_PATTERN = "\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F|\u0438\u0437|\u0433\u0437|\u043A\u0440\u043F";
2
- export declare const FLEXIBLE_LESSON_TYPE_PATTERN = "(?:\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F|\u0438\u0437|\u0433\u0437|\u043A\u0440\u043F)\\.?|\u042D\u043A\u0437";
1
+ export declare const LESSON_TYPE_PATTERN = "\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F|\u0438\u0437|\u0433\u0437|\u043A\u0440\u043F|\u043A\u0440";
2
+ export declare const FLEXIBLE_LESSON_TYPE_PATTERN = "(?:\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F|\u0438\u0437|\u0433\u0437|\u043A\u0440\u043F|\u043A\u0440)\\.?|\u042D\u043A\u0437";
3
3
  export declare const LESSON_TYPE_RE: RegExp;
4
4
  export declare const LESSON_TYPE_RE_I: RegExp;
5
5
  export declare const FLEXIBLE_LESSON_TYPE_RE_I: RegExp;
@@ -1,4 +1,4 @@
1
- export const LESSON_TYPE_PATTERN = "лк|пр|лб|зачо|зач|экз|конс|кп|из|гз|крп";
1
+ export const LESSON_TYPE_PATTERN = "лк|пр|лб|зачо|зач|экз|конс|кп|из|гз|крп|кр";
2
2
  export const FLEXIBLE_LESSON_TYPE_PATTERN = `(?:${LESSON_TYPE_PATTERN})\\.?|Экз`;
3
3
  export const LESSON_TYPE_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "i");
4
4
  export const LESSON_TYPE_RE_I = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "i");
@@ -2,8 +2,8 @@ import { parseHtml, parseTime, parseWeekParity, parseWeeks, text, } from "../../
2
2
  import { parseSemesterScheduleWith, parseSessionScheduleWith, } from "./full-schedule.js";
3
3
  import { parseGroupsString } from "./groups.js";
4
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
5
- import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
6
- import { containsGroupCode, hasDistanceMarker, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
5
+ import { SUBGROUP_RE, WEEKS_RE } from "./patterns.js";
6
+ import { containsGroupCode, hasDistanceMarker, linesAfterSubject, parseEntryRoom, parseLessonTypeAfterSubject, stripDistanceMarker, } from "./entry-parts.js";
7
7
  export function parseTeacherSchedule(html) {
8
8
  const doc = parseHtml(html);
9
9
  if (doc.querySelector('td[id^="trd2"]')) {
@@ -49,10 +49,11 @@ function parseTeacherSemesterEntry(el) {
49
49
  cleanText = cleanText.replace(text(div), "");
50
50
  }
51
51
  const subjectEl = td.querySelector('span[style*="color: blue"]');
52
- const subject = subjectEl ? text(subjectEl) : "";
52
+ if (!subjectEl)
53
+ return null;
54
+ const subject = text(subjectEl);
53
55
  if (!subject)
54
56
  return null;
55
- const typeMatch = cleanText.match(LESSON_TYPE_RE);
56
57
  const weeksMatch = cleanText.match(WEEKS_RE);
57
58
  const room = parseEntryRoom(cleanHtml, subject);
58
59
  const groupsLine = linesAfterSubject(cleanHtml, subject).find((line) => containsGroupCode(stripDistanceMarker(line))) ?? "";
@@ -62,7 +63,7 @@ function parseTeacherSemesterEntry(el) {
62
63
  return {
63
64
  room: room || undefined,
64
65
  subject,
65
- type: typeMatch?.[1] ?? "",
66
+ type: parseLessonTypeAfterSubject(subjectEl),
66
67
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
67
68
  groups: groups.length > 0 ? groups : undefined,
68
69
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
@@ -79,12 +80,13 @@ function parseTeacherSessionEntry(td) {
79
80
  return null;
80
81
  const possibleChanges = (td.getAttribute("class") ?? "").includes("want") || undefined;
81
82
  const subjectEl = td.querySelector('span[style*="color: blue"]');
82
- const subject = subjectEl ? text(subjectEl) : "";
83
+ if (!subjectEl)
84
+ return null;
85
+ const subject = text(subjectEl);
83
86
  if (!subject)
84
87
  return null;
85
88
  const room = parseEntryRoom(fullHtml, subject);
86
- const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
87
- const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
89
+ const type = parseLessonTypeAfterSubject(subjectEl).toLowerCase();
88
90
  const subgroupMatch = plainText.match(SUBGROUP_RE);
89
91
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
90
92
  if (!timeMatch)
@@ -71,6 +71,7 @@ export interface ParsedLesson {
71
71
  /** Missing means this source projection does not expose room information. */
72
72
  room?: string | null;
73
73
  subject: string;
74
+ /** Free-form portal label; empty when this source does not expose one. */
74
75
  type: string;
75
76
  /** Recurrence range; absent for concrete session lessons or unknown evidence. */
76
77
  weeks?: WeekRange;
@@ -292,25 +292,31 @@ const snapshot = await client.exportRepository();
292
292
  снимка. Используйте его для экспорта, резервной копии или миграции, не на пути
293
293
  каждого запроса.
294
294
 
295
- Для API, SSR и офлайн-кеша материализуйте только готовое расписание владельца:
295
+ Для API, SSR и офлайн-кеша передавайте компактное расписание владельца:
296
296
 
297
297
  ```ts
298
- // Сервер: агрегация выполняется один раз, источники можно убрать из payload.
299
- const payload = schedule.materializeSnapshot({ includeSources: false });
298
+ import { createScheduleSnapshot } from "chuvsu-js";
299
+
300
+ // Сервер: повторяющиеся серии + отдельные пары с датами.
301
+ const payload = createScheduleSnapshot(schedule, { includeSources: false });
300
302
  return Response.json(payload);
301
303
 
302
- // Браузер: JSON восстанавливается в индексированное по дате представление.
303
- import { MaterializedSchedule } from "chuvsu-js/browser";
304
+ // Браузер: даты строятся из правил расписания.
305
+ import { ScheduleSnapshot } from "chuvsu-js/browser";
304
306
 
305
- const schedule = new MaterializedSchedule(await response.json());
306
- schedule.on(date); // O(1) поиск даты
307
+ const schedule = new ScheduleSnapshot(await response.json());
308
+ schedule.on(date);
307
309
  schedule.dateKeys({ subgroup: 2 }); // даты для календаря
308
310
  ```
309
311
 
310
- `MaterializedScheduleSnapshot` содержит уже объединенные `LessonOccurrence`,
311
- но не канонический репозиторий. Поле `repositoryRevision` позволяет серверному
312
- кешу инвалидировать снимок после дополнения пары из расписания преподавателя,
313
- аудитории или другой группы.
312
+ `SerializedScheduleSnapshot` содержит серии и отдельные пары, но не канонический
313
+ репозиторий. `repositoryRevision` позволяет серверному кешу инвалидировать снимок
314
+ после дополнения пары из другого расписания. ` ScheduleSnapshot` использует ту же
315
+ логику дат, переносов, праздников и подгрупп, что и `Schedule`.
316
+
317
+ Формат `SerializedScheduleSnapshot` имеет `schemaVersion: 2`.
318
+ Новые UUID идентификаторы серий и пар кодируются короче; для перевода сохранённых
319
+ идентификаторов v5 используйте `migrateLegacyLessonId`.
314
320
 
315
321
  Не передавайте экспорт `exportCache()` из v4 в `importCache()` v5: изменились
316
322
  ключи, категории и форма закешированных страниц. Старый TTL-кеш и старые blob-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "5.2.1",
3
+ "version": "5.3.0",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,40 +0,0 @@
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;
@@ -1,132 +0,0 @@
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
- }