- Added dashboard functionality at /status with Basic Auth using DASHBOARD_PASSWORD. - Created server-side event logging to track catalog and university status. - Implemented traffic metrics tracking and display on the dashboard. - Enhanced API tests to cover new dashboard features and authentication. - Introduced CSS and JS for dashboard styling and functionality. - Added tests for dashboard access control and data retrieval.
118 lines
6.3 KiB
JavaScript
118 lines
6.3 KiB
JavaScript
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 && {
|
||
// Word repeats a group in some headers ("24-ИЯ (РА)и-1_…и-2"): once is enough.
|
||
groups: [...new Set(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,
|
||
};
|
||
}
|