Files
psu-schedule/server/diagnostics.js
Zakhar Timoshenko e38c388462 Add course schedules for ISF and ITZS programs for 2026 and 2024 sessions
- Created new snapshot files for ISF credits and daily schedules for the 26-АРХ and 26-ПГС programs.
- Added summer 2026 and winter 2024 exam schedules for the 23-ИТзс program, detailing lectures, labs, and consultations.
2026-10-19 22:55:28 +03:00

117 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseWeekRanges, weekday, DATE_LIST } from "../shared/weeks.js";
// The parser never guesses: a cell it cannot read becomes a lesson "every week" or keeps its
// whole text as the subject. These checks find such cells after parsing, so a new format or a
// typo in Word shows up as soon as the document is published instead of when a student notices.
const MONTHS = /(?<![а-яё])(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*/iu;
// The longest semester has about twenty weeks; "27н." is a typo, not a week.
const LAST_WEEK = 24;
const message = {
weeks_unreadable: "Пометка недель не распознана: занятие показывается каждую неделю.",
weeks_out_of_range: "Номер недели больше, чем недель в семестре.",
weeks_like_date: "Пометка недель похожа на дату с опечаткой.",
dates_unread: "В ячейке есть даты, но ни одна не распознана: занятие показывается каждую неделю.",
date_weekday: "Дата приходится на другой день недели, чем строка таблицы.",
date_out_of_year: "Дата вне учебного года документа.",
weeks_partly: "Пометка недель прочитана не целиком: часть недель попала в название предмета.",
date_in_subject: "Дата в начале ячейки не распознана: занятие показывается каждую неделю.",
time_in_subject: "Время в начале ячейки не распознано: занятие стоит во время пары, а время попало в название.",
subject_missing: "Название предмета не выделено.",
time_invalid: "Время пары не распознано или выглядит неправдоподобно.",
group_empty: "В заголовке есть группа, но ни одного её занятия.",
};
/** Problems found in one parsed document: `{code, message, groups, day, start, raw, …}`. */
export function diagnose(schedule) {
const found = [];
const add = (code, lesson, extra = {}) =>
found.push({
code,
message: message[code],
...(lesson && {
groups: lesson.groups,
day: lesson.day,
start: lesson.start || null,
raw: lesson.raw,
}),
...extra,
});
const year = Number(schedule.academicYear) || null;
for (const lesson of schedule.lessons) {
const raw = lesson.raw || "";
const dated = Boolean(lesson.date) || lesson.dates?.length > 0;
if (lesson.condition && !dated) {
const ranges = lesson.weekRanges ?? parseWeekRanges(lesson.condition);
// "19,11, 03.12, 17.12": a date typed with a comma reads as weeks 19 and 11.
if (!/н/i.test(lesson.condition) && DATE_LIST.test(raw.slice(lesson.condition.length - 1)))
add("weeks_like_date", lesson, { condition: lesson.condition });
else if (!ranges) add("weeks_unreadable", lesson, { condition: lesson.condition });
else if (ranges.some(([a, b]) => Math.max(a, b ?? a) > LAST_WEEK))
add("weeks_out_of_range", lesson, { condition: lesson.condition });
DATE_LIST.lastIndex = 0;
}
if (!dated && !lesson.condition && (MONTHS.test(raw) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(raw)))
add("dates_unread", lesson);
for (const date of lesson.date ? [lesson.date] : lesson.dates || []) {
if (weekday(date) !== lesson.day) add("date_weekday", lesson, { date });
if (year && (date < `${year}-08-01` || date > `${year + 1}-08-31`))
add("date_out_of_year", lesson, { date });
}
const subject = (lesson.subject || "").trim();
const lead = subject.match(/^(\d{1,2})([.:])(\d{2})(\.?)(?:\s*[-–]\s*\d{1,2}[.:]\d{2})?/);
if (/^[;,]?\s*\d{1,2}(?:\s*[-–,;]\s*\d{1,2})*\s*н/i.test(subject)) add("weeks_partly", lesson, { subject });
else if (lead) {
const [whole, a, sep, b, dot] = lead;
// "3.12" or "24.11." is a date, "9.00", "17.00" or "11:10-11:30" a time.
const date = sep === "." && whole === `${a}.${b}${dot}` && Number(b) >= 1 && Number(b) <= 12 && (dot || Number(a) < 7 || Number(a) > 23);
add(date ? "date_in_subject" : "time_in_subject", lesson, { subject });
} else if (!subject || /^[\d.,;:–-]/.test(subject) || (subject === raw.trim() && raw.length > 120))
add("subject_missing", lesson, { subject });
const start = lesson.start || "";
if (!/^\d{2}:\d{2}$/.test(start) || start < "07:00" || start > "21:00" || (lesson.end && lesson.end <= start))
add("time_invalid", lesson, { end: lesson.end || null });
}
for (const group of schedule.groups)
if (!schedule.lessons.some((l) => l.groups.includes(group))) add("group_empty", null, { groups: [group] });
return found;
}
const reports = new WeakMap();
/**
* Every catalog document's problems, newest index only: computed once per index build.
* Filters: `group` (as printed, "23-АРХ"), `doc` (catalog id) and `code`.
*/
export function catalogDiagnostics(idx, { group = "", doc = "", code = "" } = {}) {
if (!reports.has(idx))
reports.set(
idx,
idx.docs.map(({ id, file, schedule }) => ({
doc: { id, name: file.name, faculty: file.faculty || schedule.source.faculty || null, url: file.url || null },
problems: diagnose(schedule),
})),
);
const key = group.replace(/\s+/g, "").toLowerCase();
const items = reports
.get(idx)
.filter((r) => !doc || r.doc.id === doc)
.map((r) => ({
...r,
problems: r.problems.filter(
(p) => (!code || p.code === code) && (!key || p.groups?.some((g) => g.toLowerCase() === key)),
),
}))
.filter((r) => r.problems.length);
const counts = {};
for (const r of items) for (const p of r.problems) counts[p.code] = (counts[p.code] || 0) + 1;
return {
builtAt: idx.builtAt,
documents: idx.docs.length,
problems: items.reduce((n, r) => n + r.problems.length, 0),
counts,
failed: doc || group || code ? [] : idx.failed,
items,
};
}