chuvsu-js 5.3.0 → 5.4.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.
@@ -2,7 +2,9 @@ export declare enum AcademicPeriod {
2
2
  FallSemester = 1,
3
3
  WinterSession = 2,
4
4
  SpringSemester = 3,
5
- SummerSession = 4
5
+ SummerSession = 4,
6
+ StateFinalAssessment = 9,
7
+ Retake = 11
6
8
  }
7
9
  export declare enum EducationLevel {
8
10
  HigherEducation = 1,
@@ -4,6 +4,8 @@ export var AcademicPeriod;
4
4
  AcademicPeriod[AcademicPeriod["WinterSession"] = 2] = "WinterSession";
5
5
  AcademicPeriod[AcademicPeriod["SpringSemester"] = 3] = "SpringSemester";
6
6
  AcademicPeriod[AcademicPeriod["SummerSession"] = 4] = "SummerSession";
7
+ AcademicPeriod[AcademicPeriod["StateFinalAssessment"] = 9] = "StateFinalAssessment";
8
+ AcademicPeriod[AcademicPeriod["Retake"] = 11] = "Retake";
7
9
  })(AcademicPeriod || (AcademicPeriod = {}));
8
10
  export var EducationLevel;
9
11
  (function (EducationLevel) {
package/dist/core.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./tt/domain/index.js";
2
2
  export { attachWebinars, findWebinar } from "./tt/webinars.js";
3
3
  export type { LessonWithWebinar } from "./tt/webinars.js";
4
- export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, formatLocalDate, getHolidayTransfers, getSemesterStart, getSemesterWeeks, getStandardScheduleBlocks, getWeekNumber, getWeekdayName, isHoliday, isLocalDate, isSessionPeriod, parseLocalDate, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
4
+ export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, formatLocalDate, getHolidayTransfers, getSemesterStart, getSemesterWeeks, getStandardScheduleBlocks, getWeekNumber, getWeekdayName, isHoliday, isLocalDate, isSessionPeriod, LessonType, parseLessonType, parseLocalDate, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
5
5
  export type { Holiday, HolidayTransfer } from "./tt/utils/index.js";
6
6
  export { AuthError, EducationLevel, ParseError, AcademicPeriod, } from "./common/types.js";
7
7
  export type { LocalDate, Teacher, Time, TimeRange, WeekRange, } from "./common/types.js";
package/dist/core.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Runtime-safe core for Node.js, browsers, Deno, and workers.
2
2
  export * from "./tt/domain/index.js";
3
3
  export { attachWebinars, findWebinar } from "./tt/webinars.js";
4
- export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, formatLocalDate, getHolidayTransfers, getSemesterStart, getSemesterWeeks, getStandardScheduleBlocks, getWeekNumber, getWeekdayName, isHoliday, isLocalDate, isSessionPeriod, parseLocalDate, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
4
+ export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, formatLocalDate, getHolidayTransfers, getSemesterStart, getSemesterWeeks, getStandardScheduleBlocks, getWeekNumber, getWeekdayName, isHoliday, isLocalDate, isSessionPeriod, LessonType, parseLessonType, parseLocalDate, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
5
5
  export { AuthError, EducationLevel, ParseError, AcademicPeriod, } from "./common/types.js";
package/dist/parsers.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { parseAcademicYearFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseFacultyButtons, parseGroupSchedule, parseGroupName, parseGroupButtons, parseGroupsString, parsePeriodFromPage, parseTeacherButtons, parseTeacherSchedule, parseTeacherInfo, parseWebinars, } from "./tt/parse/index.js";
1
+ export { parseAcademicYearFromPage, parseAvailablePeriodsFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseFacultyButtons, parseGroupSchedule, parseGroupName, parseGroupButtons, parseGroupsString, parsePeriodFromPage, parseTeacherButtons, parseTeacherSchedule, parseTeacherInfo, parseWebinars, } from "./tt/parse/index.js";
2
2
  export { createScheduleSourceSnapshot } from "./tt/observations.js";
3
3
  export type { ParsedScheduleDay, ParsedLesson, ParsedScheduleBlock, } from "./tt/types.js";
package/dist/parsers.js CHANGED
@@ -1,2 +1,2 @@
1
- export { parseAcademicYearFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseFacultyButtons, parseGroupSchedule, parseGroupName, parseGroupButtons, parseGroupsString, parsePeriodFromPage, parseTeacherButtons, parseTeacherSchedule, parseTeacherInfo, parseWebinars, } from "./tt/parse/index.js";
1
+ export { parseAcademicYearFromPage, parseAvailablePeriodsFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseFacultyButtons, parseGroupSchedule, parseGroupName, parseGroupButtons, parseGroupsString, parsePeriodFromPage, parseTeacherButtons, parseTeacherSchedule, parseTeacherInfo, parseWebinars, } from "./tt/parse/index.js";
2
2
  export { createScheduleSourceSnapshot } from "./tt/observations.js";
@@ -13,7 +13,7 @@ export declare class TimetableClient {
13
13
  private repositoryReady;
14
14
  private repositoryMutation;
15
15
  private loginMode;
16
- private timetableContext;
16
+ private timetableContexts;
17
17
  constructor(opts?: TimetableClientOptions);
18
18
  private get pertt();
19
19
  get repository(): TimetableRepository;
package/dist/tt/client.js CHANGED
@@ -1,18 +1,20 @@
1
1
  import { HttpClient, } from "../common/http.js";
2
2
  import { HybridCache } from "../common/cache.js";
3
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";
4
+ import { parseAcademicYearFromPage, parseAvailablePeriodsFromPage, parseRoomButtons, parseRoomSchedule, parseRoomInfo, parseRoomName, parseGroupButtons, parseGroupName, parseFacultyButtons, parseTeacherButtons, parseGroupSchedule, parseTeacherSchedule, parseTeacherInfo, parsePeriodFromPage, parseWebinars, } from "./parse/index.js";
5
5
  import { Schedule } from "./domain/schedule.js";
6
6
  import { TimetableRepository } from "./domain/repository.js";
7
7
  import { createScheduleSourceSnapshot } from "./observations.js";
8
8
  const BASE = "https://tt.chuvsu.ru";
9
9
  const AUTH_URL = `${BASE}/auth`;
10
10
  const TIMETABLE_CONTEXT_TTL_MS = 15 * 60 * 1000;
11
- const ALL_PERIODS = [
11
+ const SUPPORTED_PERIODS = [
12
12
  AcademicPeriod.FallSemester,
13
13
  AcademicPeriod.WinterSession,
14
14
  AcademicPeriod.SpringSemester,
15
15
  AcademicPeriod.SummerSession,
16
+ AcademicPeriod.StateFinalAssessment,
17
+ AcademicPeriod.Retake,
16
18
  ];
17
19
  function requirePositiveId(value, label) {
18
20
  if (!Number.isInteger(value) || value == null || value < 1) {
@@ -28,12 +30,12 @@ function normalizeSearchQuery(value, label, minimumLength = 1) {
28
30
  return normalized;
29
31
  }
30
32
  function normalizePeriods(periods) {
31
- const values = [...(periods ?? ALL_PERIODS)];
33
+ const values = [...periods];
32
34
  if (values.length === 0)
33
35
  throw new RangeError("At least one period is required");
34
36
  const unique = new Set();
35
37
  for (const period of values) {
36
- if (!ALL_PERIODS.includes(period)) {
38
+ if (!SUPPORTED_PERIODS.includes(period)) {
37
39
  throw new RangeError(`Invalid academic period: ${period}`);
38
40
  }
39
41
  if (unique.has(period)) {
@@ -74,7 +76,7 @@ export class TimetableClient {
74
76
  repositoryReady = null;
75
77
  repositoryMutation = Promise.resolve();
76
78
  loginMode = null;
77
- timetableContext = null;
79
+ timetableContexts = new Map();
78
80
  constructor(opts) {
79
81
  this.educationLevel = opts?.educationLevel ?? EducationLevel.HigherEducation;
80
82
  if (this.educationLevel !== EducationLevel.HigherEducation &&
@@ -189,7 +191,7 @@ export class TimetableClient {
189
191
  throw new AuthError("TT login failed");
190
192
  }
191
193
  this.loginMode = { type: "credentials", ...opts };
192
- this.timetableContext = null;
194
+ this.timetableContexts.clear();
193
195
  }
194
196
  async loginAsGuest() {
195
197
  const res = await this.http.post(AUTH_URL, { guest: "Войти гостем", hfac: "0", pertt: this.pertt }, false);
@@ -197,7 +199,7 @@ export class TimetableClient {
197
199
  throw new AuthError("TT guest login failed");
198
200
  }
199
201
  this.loginMode = { type: "guest" };
200
- this.timetableContext = null;
202
+ this.timetableContexts.clear();
201
203
  }
202
204
  isSessionExpired(body) {
203
205
  return body.includes('name="wname"');
@@ -260,10 +262,9 @@ export class TimetableClient {
260
262
  }
261
263
  /** Resolve the academic year and active period from tt.chuvsu.ru itself. */
262
264
  async getTimetableContext(pageUrl) {
263
- if (this.timetableContext &&
264
- Date.now() - this.timetableContext.resolvedAt < TIMETABLE_CONTEXT_TTL_MS) {
265
- return this.timetableContext;
266
- }
265
+ const cached = this.timetableContexts.get(pageUrl);
266
+ if (cached && Date.now() - cached.resolvedAt < TIMETABLE_CONTEXT_TTL_MS)
267
+ return cached;
267
268
  const { body } = await this.authGet(pageUrl);
268
269
  const academicYearStartYear = parseAcademicYearFromPage(body);
269
270
  if (academicYearStartYear == null) {
@@ -273,12 +274,14 @@ export class TimetableClient {
273
274
  if (period == null) {
274
275
  throw new ParseError("TT page does not expose the active timetable period");
275
276
  }
277
+ const availablePeriods = parseAvailablePeriodsFromPage(body);
276
278
  const context = {
277
279
  academicYearStartYear,
278
280
  period,
281
+ availablePeriods: availablePeriods.length ? availablePeriods : [period],
279
282
  resolvedAt: Date.now(),
280
283
  };
281
- this.timetableContext = context;
284
+ this.timetableContexts.set(pageUrl, context);
282
285
  return context;
283
286
  }
284
287
  // --- Schedule ---
@@ -359,7 +362,7 @@ export class TimetableClient {
359
362
  }
360
363
  async getSchedule(owner, options) {
361
364
  const ownerUrl = this.ownerUrl(owner);
362
- const periods = normalizePeriods(options?.periods);
365
+ const requestedPeriods = options?.periods ? normalizePeriods(options.periods) : null;
363
366
  await this.ensureRepository();
364
367
  if (owner.type === "group" && owner.group.name.trim()) {
365
368
  await this.rememberGroups([owner.group]);
@@ -372,6 +375,7 @@ export class TimetableClient {
372
375
  }
373
376
  const resolvedOwner = this.resolveOwner(owner);
374
377
  const context = await this.getTimetableContext(ownerUrl);
378
+ const periods = requestedPeriods ?? context.availablePeriods;
375
379
  const results = await Promise.all(periods.map(async (period) => ({
376
380
  period,
377
381
  days: await this.fetchOwnerSchedule(resolvedOwner, period, context.academicYearStartYear),
@@ -1,5 +1,5 @@
1
1
  export { RandomLessonIdGenerator, occurrenceIdForSeries, migrateLegacyLessonId } from "./ids.js";
2
- export { ScheduleSnapshot, createScheduleSnapshot } from "./schedule-snapshot.js";
2
+ export { ScheduleSnapshot, createScheduleSnapshot, SCHEDULE_SNAPSHOT_SCHEMA_VERSION } from "./schedule-snapshot.js";
3
3
  export type { SerializedScheduleSnapshot } from "./schedule-snapshot.js";
4
4
  export { TimetableDirectory } from "./directory.js";
5
5
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
@@ -1,5 +1,5 @@
1
1
  export { RandomLessonIdGenerator, occurrenceIdForSeries, migrateLegacyLessonId } from "./ids.js";
2
- export { ScheduleSnapshot, createScheduleSnapshot } from "./schedule-snapshot.js";
2
+ export { ScheduleSnapshot, createScheduleSnapshot, SCHEDULE_SNAPSHOT_SCHEMA_VERSION } from "./schedule-snapshot.js";
3
3
  export { TimetableDirectory } from "./directory.js";
4
4
  export { entityKey, mergeEntityRefs, mergeGroups, mergeRooms, mergeTeachers, normalizeScheduleText, } from "./normalize.js";
5
5
  export { MemoryTimetableRepositoryAdapter, TimetableRepository, } from "./repository.js";
@@ -1,4 +1,4 @@
1
- import type { AcademicPeriod } from "../../common/types.js";
1
+ import { AcademicPeriod } from "../../common/types.js";
2
2
  import { TimetableDirectory } from "./directory.js";
3
3
  import type { IngestResult, LessonIdGenerator, LessonOccurrence, LessonSeries, ScheduleOwner, ScheduleSourceSnapshot, TimetableRepositoryAdapter, TimetableRepositoryPatch, TimetableRepositorySnapshot } from "./types.js";
4
4
  export declare class TimetableRepository {
@@ -1,3 +1,4 @@
1
+ import { AcademicPeriod } from "../../common/types.js";
1
2
  import { isLocalDate } from "../utils/date.js";
2
3
  import { TimetableDirectory } from "./directory.js";
3
4
  import { RandomLessonIdGenerator } from "./ids.js";
@@ -9,7 +10,7 @@ function sourceObservationKey(sourceKey, observationKey) {
9
10
  return claimKey(sourceKey, observationKey);
10
11
  }
11
12
  function canonicalBucketKey(observation, source) {
12
- const prefix = `${source.academicYearStartYear}:${source.period}:${observation.kind}:${normalizeScheduleText(observation.subject)}`;
13
+ const prefix = `${source.academicYearStartYear}:${source.period}:${observation.kind}:${observation.retakeAttempt ?? 0}:${normalizeScheduleText(observation.subject)}`;
13
14
  return observation.kind === "series"
14
15
  ? `${prefix}:${observation.recurrence.weekday}:${recurrenceWeeks(observation)}`
15
16
  : `${prefix}:${observation.date}`;
@@ -88,7 +89,7 @@ function observationScore(left, right, sameSource, directory) {
88
89
  return Number.NEGATIVE_INFINITY;
89
90
  }
90
91
  let score = 35;
91
- if (normalizeScheduleText(left.type) === normalizeScheduleText(right.type)) {
92
+ if (left.type === right.type) {
92
93
  score += 10;
93
94
  }
94
95
  else if (left.type && right.type) {
@@ -570,7 +571,7 @@ export class TimetableRepository {
570
571
  if (!Number.isInteger(source.academicYearStartYear)) {
571
572
  throw new Error(`Invalid academic year for source ${source.sourceKey}`);
572
573
  }
573
- if (!Number.isInteger(source.period) || source.period < 1 || source.period > 4) {
574
+ if (!Number.isInteger(source.period) || !Object.values(AcademicPeriod).includes(source.period)) {
574
575
  throw new Error(`Invalid academic period for source ${source.sourceKey}`);
575
576
  }
576
577
  if (!(source.observedAt instanceof Date) || !Number.isFinite(source.observedAt.getTime())) {
@@ -939,6 +940,7 @@ export class TimetableRepository {
939
940
  ...relations,
940
941
  isDistance: claims.some((value) => value.observation.isDistance === true),
941
942
  possibleChanges: claims.some((value) => value.observation.possibleChanges === true),
943
+ ...(preferred.observation.retakeAttempt ? { retakeAttempt: preferred.observation.retakeAttempt } : {}),
942
944
  substitutions: this.aggregateSubstitutions(claims),
943
945
  sources: claims.map(sourceRef),
944
946
  };
@@ -980,6 +982,7 @@ export class TimetableRepository {
980
982
  ...relations,
981
983
  isDistance: claims.some((value) => value.observation.isDistance === true),
982
984
  possibleChanges: claims.some((value) => value.observation.possibleChanges === true),
985
+ ...(preferred.observation.retakeAttempt ? { retakeAttempt: preferred.observation.retakeAttempt } : {}),
983
986
  status: transfer ? "moved" : "scheduled",
984
987
  movedFrom: transfer
985
988
  ? { date: transfer.fromDate, slotNumber: transfer.fromSlot }
@@ -11,8 +11,9 @@ type SerializedSeries = Omit<LessonSeries, "sources"> & {
11
11
  type SerializedOccurrence = Omit<LessonOccurrence, "sources"> & {
12
12
  sources: SerializedSource[];
13
13
  };
14
+ export declare const SCHEDULE_SNAPSHOT_SCHEMA_VERSION = 3;
14
15
  export interface SerializedScheduleSnapshot {
15
- schemaVersion: 2;
16
+ schemaVersion: typeof SCHEDULE_SNAPSHOT_SCHEMA_VERSION;
16
17
  repositoryRevision: number;
17
18
  owner: ScheduleOwner;
18
19
  academicYearStartYear: number;
@@ -21,8 +22,8 @@ export interface SerializedScheduleSnapshot {
21
22
  end: LocalDate;
22
23
  series: SerializedSeries[];
23
24
  direct: SerializedOccurrence[];
24
- holidays: Holiday[];
25
- holidayTransfers: {
25
+ holidays?: Holiday[];
26
+ holidayTransfers?: {
26
27
  dayOff: string;
27
28
  workDay: string | null;
28
29
  }[];
@@ -1,5 +1,6 @@
1
- import { formatLocalDate, parseLocalDate } from "../utils/index.js";
1
+ import { formatLocalDate, parseLocalDate, RUSSIAN_HOLIDAYS } from "../utils/index.js";
2
2
  import { Schedule } from "./schedule.js";
3
+ export const SCHEDULE_SNAPSHOT_SCHEMA_VERSION = 3;
3
4
  function serializeSources(sources, include) {
4
5
  return include
5
6
  ? sources.map((source) => ({ ...structuredClone(source), observedAt: source.observedAt.toISOString() }))
@@ -8,6 +9,12 @@ function serializeSources(sources, include) {
8
9
  function deserializeSources(sources) {
9
10
  return sources.map((source) => ({ ...structuredClone(source), observedAt: new Date(source.observedAt) }));
10
11
  }
12
+ function usesDefaultHolidays(holidays) {
13
+ return holidays.length === RUSSIAN_HOLIDAYS.length && holidays.every((holiday, index) => {
14
+ const standard = RUSSIAN_HOLIDAYS[index];
15
+ return holiday.month === standard.month && holiday.day === standard.day && holiday.name === standard.name;
16
+ });
17
+ }
11
18
  /** Browser-ready schedule backed by recurring rules and dated exceptions. */
12
19
  export class ScheduleSnapshot extends Schedule {
13
20
  snapshot;
@@ -15,7 +22,7 @@ export class ScheduleSnapshot extends Schedule {
15
22
  snapshotDirect;
16
23
  dateSetCache = new Map();
17
24
  constructor(snapshot) {
18
- if (snapshot.schemaVersion !== 2) {
25
+ if (snapshot.schemaVersion !== SCHEDULE_SNAPSHOT_SCHEMA_VERSION) {
19
26
  throw new Error(`Unsupported schedule snapshot schema: ${snapshot.schemaVersion}`);
20
27
  }
21
28
  const series = snapshot.series.map((value) => ({
@@ -27,7 +34,7 @@ export class ScheduleSnapshot extends Schedule {
27
34
  const options = {
28
35
  period: snapshot.period,
29
36
  holidays: snapshot.holidays,
30
- holidayTransfers: snapshot.holidayTransfers.map((value) => ({
37
+ holidayTransfers: snapshot.holidayTransfers?.map((value) => ({
31
38
  dayOff: new Date(value.dayOff),
32
39
  workDay: value.workDay ? new Date(value.workDay) : null,
33
40
  })),
@@ -77,7 +84,7 @@ export function createScheduleSnapshot(schedule, options) {
77
84
  ...value, sources: serializeSources(value.sources, includeSources),
78
85
  }));
79
86
  return {
80
- schemaVersion: 2,
87
+ schemaVersion: SCHEDULE_SNAPSHOT_SCHEMA_VERSION,
81
88
  repositoryRevision: schedule.revision,
82
89
  owner: structuredClone(schedule.owner),
83
90
  academicYearStartYear: schedule.academicYearStartYear,
@@ -86,10 +93,12 @@ export function createScheduleSnapshot(schedule, options) {
86
93
  end: formatLocalDate(end),
87
94
  series,
88
95
  direct,
89
- holidays: structuredClone(schedule.holidays),
90
- holidayTransfers: schedule.holidayTransfers.map((value) => ({
91
- dayOff: value.dayOff.toISOString(),
92
- workDay: value.workDay?.toISOString() ?? null,
93
- })),
96
+ ...(!usesDefaultHolidays(schedule.holidays) ? { holidays: structuredClone(schedule.holidays) } : {}),
97
+ ...(schedule.holidayTransfers.length ? {
98
+ holidayTransfers: schedule.holidayTransfers.map((value) => ({
99
+ dayOff: value.dayOff.toISOString(),
100
+ workDay: value.workDay?.toISOString() ?? null,
101
+ })),
102
+ } : {}),
94
103
  };
95
104
  }
@@ -181,6 +181,7 @@ export class Schedule {
181
181
  rooms,
182
182
  isDistance,
183
183
  possibleChanges: series.possibleChanges,
184
+ ...(series.retakeAttempt ? { retakeAttempt: series.retakeAttempt } : {}),
184
185
  status: "scheduled",
185
186
  originalRooms,
186
187
  originalTeachers,
@@ -1,4 +1,5 @@
1
1
  import type { AcademicPeriod, LocalDate, TimeRange, WeekRange } from "../../common/types.js";
2
+ import type { LessonType } from "../utils/lesson-type.js";
2
3
  export type LessonSeriesId = string;
3
4
  export type LessonId = string;
4
5
  export interface NamedEntityRef {
@@ -62,8 +63,7 @@ export interface LessonSeries {
62
63
  academicYearStartYear: number;
63
64
  period: AcademicPeriod;
64
65
  subject: string;
65
- /** Free-form portal label; empty when no source exposes one. */
66
- type: string;
66
+ type: LessonType;
67
67
  slotNumber?: number;
68
68
  time?: TimeRange;
69
69
  recurrence: LessonRecurrence;
@@ -72,6 +72,7 @@ export interface LessonSeries {
72
72
  rooms: RelationSet<RoomRef>;
73
73
  isDistance: boolean;
74
74
  possibleChanges: boolean;
75
+ retakeAttempt?: 1 | 2;
75
76
  substitutions: LessonSubstitution[];
76
77
  sources: LessonSourceRef[];
77
78
  }
@@ -85,8 +86,7 @@ export interface LessonOccurrence {
85
86
  nominalDate: LocalDate;
86
87
  scheduledDate: LocalDate;
87
88
  subject: string;
88
- /** Free-form portal label; empty when no source exposes one. */
89
- type: string;
89
+ type: LessonType;
90
90
  slotNumber?: number;
91
91
  time?: TimeRange;
92
92
  groups: RelationSet<GroupAttendance>;
@@ -94,6 +94,7 @@ export interface LessonOccurrence {
94
94
  rooms: RelationSet<RoomRef>;
95
95
  isDistance: boolean;
96
96
  possibleChanges: boolean;
97
+ retakeAttempt?: 1 | 2;
97
98
  status: LessonStatus;
98
99
  movedFrom?: {
99
100
  date: LocalDate;
@@ -107,8 +108,7 @@ interface ObservationBase {
107
108
  /** Stable only inside one source page projection. */
108
109
  key: string;
109
110
  subject: string;
110
- /** Free-form portal label; empty when this source does not expose one. */
111
- type: string;
111
+ type: LessonType;
112
112
  slotNumber?: number;
113
113
  time?: TimeRange;
114
114
  groups: RelationSet<GroupAttendance>;
@@ -116,6 +116,7 @@ interface ObservationBase {
116
116
  rooms: RelationSet<RoomRef>;
117
117
  isDistance?: boolean;
118
118
  possibleChanges?: boolean;
119
+ retakeAttempt?: 1 | 2;
119
120
  substitutions?: LessonSubstitution[];
120
121
  }
121
122
  export interface SeriesObservation extends ObservationBase {
@@ -1,4 +1,4 @@
1
- import { parseLocalDate } from "./utils/index.js";
1
+ import { parseLessonType, parseLocalDate } from "./utils/index.js";
2
2
  const WEEKDAY_INDEX = new Map([
3
3
  ["воскресенье", 0],
4
4
  ["понедельник", 1],
@@ -67,7 +67,7 @@ function observationBase(key, entry, block, owner) {
67
67
  return {
68
68
  key,
69
69
  subject: entry.subject,
70
- type: entry.type,
70
+ type: parseLessonType(entry.type),
71
71
  slotNumber: block.slotNumber,
72
72
  time: block.time,
73
73
  groups: groupsFor(entry, owner),
@@ -75,6 +75,7 @@ function observationBase(key, entry, block, owner) {
75
75
  rooms: roomsFor(entry, owner),
76
76
  isDistance: entry.isDistance,
77
77
  possibleChanges: entry.possibleChanges,
78
+ ...(entry.retakeAttempt ? { retakeAttempt: entry.retakeAttempt } : {}),
78
79
  substitutions: substitutionsFor(entry.substitutions),
79
80
  };
80
81
  }
@@ -1,3 +1,4 @@
1
+ export declare function parseRetakeAttempt(value: string): 1 | 2 | undefined;
1
2
  export declare function entryHtmlLines(html: string): string[];
2
3
  export declare function entryTextLines(html: string): string[];
3
4
  export declare function stripHtml(html: string): string;
@@ -5,6 +5,11 @@ const GROUP_TOKEN_RE = /^[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+$/u;
5
5
  const LEADING_PARENTHESIZED_RE = /^\s*\(([^()]*)\)/u;
6
6
  const WEEK_METADATA_RE = /(?:^|\s)нед\.?(?:\s|$)/iu;
7
7
  const SUBGROUP_METADATA_RE = /^\d+\s*подгруппа$/iu;
8
+ const RETAKE_RE = /(?:^|\s)([12])\s*ППА(?:\s|$)/iu;
9
+ export function parseRetakeAttempt(value) {
10
+ const match = value.match(RETAKE_RE);
11
+ return match ? Number(match[1]) : undefined;
12
+ }
8
13
  export function entryHtmlLines(html) {
9
14
  return html.split(BR_RE).map((line) => line.trim());
10
15
  }
@@ -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
3
  import { SUBGROUP_RE, WEEKS_RE } from "./patterns.js";
4
- import { hasDistanceMarker, containsGroupCode, linesAfterSubject, parseLessonTypeAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
4
+ import { hasDistanceMarker, containsGroupCode, linesAfterSubject, parseLessonTypeAfterSubject, parseEntryRoom, parseRetakeAttempt, stripDistanceMarker, stripHtml, } 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"
@@ -180,6 +180,7 @@ function parseSessionEntry(td) {
180
180
  // Type: parenthesized text after </span>, case-insensitive
181
181
  const type = parseLessonTypeAfterSubject(subjectEl).toLowerCase();
182
182
  const subgroupMatch = plainText.match(SUBGROUP_RE);
183
+ const retakeAttempt = parseRetakeAttempt(stripHtml(fullHtml));
183
184
  const parts = linesAfterSubject(fullHtml, subject);
184
185
  const teacherPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
185
186
  !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
@@ -198,6 +199,7 @@ function parseSessionEntry(td) {
198
199
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
199
200
  isDistance: hasDistanceMarker(plainText) || hasDistanceMarker(room ?? ""),
200
201
  possibleChanges,
202
+ ...(retakeAttempt ? { retakeAttempt } : {}),
201
203
  },
202
204
  time: { start: parseTime(timeMatch[1]), end: parseTime(timeMatch[2]) },
203
205
  };
@@ -1,5 +1,5 @@
1
1
  export { parseGroupsString } from "./groups.js";
2
- export { parseAcademicYearFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
2
+ export { parseAcademicYearFromPage, parseAvailablePeriodsFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
3
  export { parseGroupSchedule } from "./full-schedule.js";
4
4
  export { parseRoomSchedule, parseRoomInfo, } from "./audience.js";
5
5
  export { parseTeacherSchedule, parseTeacherInfo, } from "./teacher.js";
@@ -1,5 +1,5 @@
1
1
  export { parseGroupsString } from "./groups.js";
2
- export { parseAcademicYearFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
2
+ export { parseAcademicYearFromPage, parseAvailablePeriodsFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
3
  export { parseGroupSchedule } from "./full-schedule.js";
4
4
  export { parseRoomSchedule, parseRoomInfo, } from "./audience.js";
5
5
  export { parseTeacherSchedule, parseTeacherInfo, } from "./teacher.js";
@@ -1,5 +1,7 @@
1
1
  import { AcademicPeriod } from "../../common/types.js";
2
2
  import type { Faculty, Group, Room } from "../types.js";
3
+ /** Period tabs exposed by this timetable page, in portal order. */
4
+ export declare function parseAvailablePeriodsFromPage(html: string): AcademicPeriod[];
3
5
  export declare function parsePeriodFromPage(html: string): AcademicPeriod | null;
4
6
  /** Parse the start year from labels like "2026/2027 учебный год". */
5
7
  export declare function parseAcademicYearFromPage(html: string): number | null;
@@ -1,21 +1,36 @@
1
1
  import { parseHtml, text } from "../../common/parse.js";
2
+ import { AcademicPeriod } from "../../common/types.js";
2
3
  const PERIOD_LABELS = {
3
4
  "осенний семестр": 1,
4
5
  "зимняя сессия": 2,
5
6
  "весенний семестр": 3,
6
7
  "летняя сессия": 4,
8
+ "гиа": AcademicPeriod.StateFinalAssessment,
9
+ "ппа": AcademicPeriod.Retake,
7
10
  };
11
+ const KNOWN_PERIODS = new Set(Object.values(AcademicPeriod).filter((value) => typeof value === "number"));
12
+ /** Period tabs exposed by this timetable page, in portal order. */
13
+ export function parseAvailablePeriodsFromPage(html) {
14
+ const doc = parseHtml(html);
15
+ const periods = new Set();
16
+ for (const input of doc.querySelectorAll('input[name="pertype"]')) {
17
+ const value = Number(input.getAttribute("value"));
18
+ if (KNOWN_PERIODS.has(value))
19
+ periods.add(value);
20
+ }
21
+ return [...periods];
22
+ }
8
23
  export function parsePeriodFromPage(html) {
9
24
  const doc = parseHtml(html);
10
25
  // Schedule pages expose the active period as the checked radio button.
11
26
  const checked = doc.querySelector('input[name="pertype"][checked]');
12
27
  const checkedValue = Number(checked?.getAttribute("value"));
13
- if (checkedValue >= 1 && checkedValue <= 4) {
28
+ if (KNOWN_PERIODS.has(checkedValue)) {
14
29
  return checkedValue;
15
30
  }
16
31
  // Some pages keep the selected period only in the hidden form field.
17
32
  const hiddenValue = Number(doc.querySelector('#htype')?.getAttribute("value"));
18
- if (hiddenValue >= 1 && hiddenValue <= 4) {
33
+ if (KNOWN_PERIODS.has(hiddenValue)) {
19
34
  return hiddenValue;
20
35
  }
21
36
  // Legacy markup used an italic textual marker.
@@ -96,6 +96,8 @@ export interface ParsedLesson {
96
96
  substituteFor?: SubstituteForInfo;
97
97
  /** Whether this entry is marked as potentially changing (class="want"). */
98
98
  possibleChanges?: boolean;
99
+ /** Repeated interim assessment attempt (ППА), when marked by the portal. */
100
+ retakeAttempt?: 1 | 2;
99
101
  }
100
102
  export interface ParsedScheduleBlock {
101
103
  /** Portal ordinal claim. It is independent from the time claim. */
@@ -2,5 +2,6 @@ export { formatLocalDate, getMonday, getWeekdayName, isSameDay, isLocalDate, par
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
4
  export { getStandardScheduleBlocks } from "./time-slots.js";
5
+ export { LessonType, parseLessonType } from "./lesson-type.js";
5
6
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
6
7
  export type { Holiday, HolidayTransfer } from "./holidays.js";
@@ -2,4 +2,5 @@ export { formatLocalDate, getMonday, getWeekdayName, isSameDay, isLocalDate, par
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
4
  export { getStandardScheduleBlocks } from "./time-slots.js";
5
+ export { LessonType, parseLessonType } from "./lesson-type.js";
5
6
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
@@ -0,0 +1,17 @@
1
+ /** Stable compact identifiers for portal lesson types. */
2
+ export declare enum LessonType {
3
+ Unknown = 0,
4
+ Lecture = 1,
5
+ Practical = 2,
6
+ Laboratory = 3,
7
+ Consultation = 4,
8
+ Credit = 5,
9
+ GradedCredit = 6,
10
+ Exam = 7,
11
+ Individual = 8,
12
+ Group = 9,
13
+ CourseProject = 10,
14
+ Krp = 11
15
+ }
16
+ /** Convert portal short code into compact domain identifier. */
17
+ export declare function parseLessonType(code: string): LessonType;
@@ -0,0 +1,33 @@
1
+ /** Stable compact identifiers for portal lesson types. */
2
+ export var LessonType;
3
+ (function (LessonType) {
4
+ LessonType[LessonType["Unknown"] = 0] = "Unknown";
5
+ LessonType[LessonType["Lecture"] = 1] = "Lecture";
6
+ LessonType[LessonType["Practical"] = 2] = "Practical";
7
+ LessonType[LessonType["Laboratory"] = 3] = "Laboratory";
8
+ LessonType[LessonType["Consultation"] = 4] = "Consultation";
9
+ LessonType[LessonType["Credit"] = 5] = "Credit";
10
+ LessonType[LessonType["GradedCredit"] = 6] = "GradedCredit";
11
+ LessonType[LessonType["Exam"] = 7] = "Exam";
12
+ LessonType[LessonType["Individual"] = 8] = "Individual";
13
+ LessonType[LessonType["Group"] = 9] = "Group";
14
+ LessonType[LessonType["CourseProject"] = 10] = "CourseProject";
15
+ LessonType[LessonType["Krp"] = 11] = "Krp";
16
+ })(LessonType || (LessonType = {}));
17
+ const TYPE_BY_CODE = {
18
+ лк: LessonType.Lecture,
19
+ пр: LessonType.Practical,
20
+ лб: LessonType.Laboratory,
21
+ конс: LessonType.Consultation,
22
+ зач: LessonType.Credit,
23
+ зачо: LessonType.GradedCredit,
24
+ экз: LessonType.Exam,
25
+ из: LessonType.Individual,
26
+ гз: LessonType.Group,
27
+ кп: LessonType.CourseProject,
28
+ крп: LessonType.Krp,
29
+ };
30
+ /** Convert portal short code into compact domain identifier. */
31
+ export function parseLessonType(code) {
32
+ return TYPE_BY_CODE[code.trim().toLocaleLowerCase("ru-RU").replace(/\.$/u, "")] ?? LessonType.Unknown;
33
+ }
@@ -1,3 +1,4 @@
1
+ import { LessonType, parseLessonType } from "./utils/lesson-type.js";
1
2
  function normalize(value) {
2
3
  return value
3
4
  .toLocaleLowerCase("ru-RU")
@@ -25,7 +26,7 @@ export function findWebinar(lesson, webinars) {
25
26
  }
26
27
  if (normalize(webinar.subject) !== normalize(lesson.subject))
27
28
  return false;
28
- if (webinar.type && lesson.type && webinar.type !== lesson.type)
29
+ if (webinar.type && lesson.type !== LessonType.Unknown && parseLessonType(webinar.type) !== lesson.type)
29
30
  return false;
30
31
  return true;
31
32
  });
@@ -104,7 +104,10 @@ new TimetableClient({ educationLevel: EducationLevel.HigherEducation });
104
104
  | `getTeacherPhoto(id)` / `getTeacherPhotoLazy(id)` | `getTeacherPhoto(id)` |
105
105
 
106
106
  `getGroupSchedule`, `getTeacherSchedule` и `getRoomSchedule` по умолчанию
107
- загружают все четыре периода. Для ограничения запросов передайте `periods`:
107
+ загружают все периоды, доступные на соответствующей странице портала, включая
108
+ ГИА (`AcademicPeriod.StateFinalAssessment`, 9) и ППА (`AcademicPeriod.Retake`, 11).
109
+ Уроки ППА содержат `retakeAttempt: 1 | 2`, если номер попытки указан на сайте.
110
+ Для ограничения запросов передайте `periods`:
108
111
 
109
112
  ```ts
110
113
  import { AcademicPeriod } from "chuvsu-js";
@@ -117,6 +120,10 @@ const schedule = await client.getTeacherSchedule(teacherId, {
117
120
  });
118
121
  ```
119
122
 
123
+ `LessonType` хранит тип занятия числом в наблюдениях, репозитории и снимке.
124
+ `parseLessonType(code)` переводит код портала в этот индекс. Для неизвестного
125
+ кода возвращается `LessonType.Unknown`; текстовые расшифровки принадлежат клиенту.
126
+
120
127
  Для кода, который работает с разными владельцами одинаково, добавлен общий
121
128
  метод:
122
129
 
@@ -314,7 +321,7 @@ schedule.dateKeys({ subgroup: 2 }); // даты для календаря
314
321
  после дополнения пары из другого расписания. ` ScheduleSnapshot` использует ту же
315
322
  логику дат, переносов, праздников и подгрупп, что и `Schedule`.
316
323
 
317
- Формат `SerializedScheduleSnapshot` имеет `schemaVersion: 2`.
324
+ Формат `SerializedScheduleSnapshot` имеет `schemaVersion: 3`.
318
325
  Новые UUID идентификаторы серий и пар кодируются короче; для перевода сохранённых
319
326
  идентификаторов v5 используйте `migrateLegacyLessonId`.
320
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Библиотека для расписания и личного кабинета ЧГУ им. И. Н. Ульянова",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",