// University-wide directory: every catalog document parsed once and searched // by teacher, room and date. Answers come from the Word files only, so every // response carries its coverage and the caller must present it as such. import { catalogDiagnostics } from "./diagnostics.js"; import { getCatalog, getCalendar } from "./source.js"; import { scheduleFor } from "./schedules.js"; import { semesterView, lessonDates, todayIso, weekday, weekIndexOf, } from "../shared/weeks.js"; let index = null, building = null; const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu; // "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits. const ROOM_TOKEN = /[А-ЯA-Z]\d-\d{2}|\d{2,4}\/\d{2,4}|\d{2,4}[А-ЯA-Z]{0,2}/gu; const ROOM_TEXT = "[А-ЯA-Z]\\d-\\d{2}|\\d{2,4}(?:\\s?(?!и(?![а-яё]))[А-Яа-я]{1,2}(?![а-яё]))?"; // "(13.10-ауд.510н)", "14.10. -ауд. 333", "07.09. ауд. 414", "07.10-203мк": on that date the lesson moves. const OVERRIDE = new RegExp( `(\\d{1,2})\\.(\\d{1,2})\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`, "giu", ); const DATE = /\d{1,2}\.\d{1,2}(?:\.\d{2,4})?\.?/g; // Week lists inside a room note: "ауд.205;6,8н.-…", "1-9н.". const WEEKS = /\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*н(?:ед)?\.?|\d{1,2}\s*н(?:ед)?\./giu; const MENTION = /(?:ауд\.?|(? (s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s.]/g, ""); export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim(); // Word files mix Latin look-alikes into room numbers ("12C" with a Latin C). const LATIN = { A: "А", B: "В", C: "С", E: "Е", H: "Н", K: "К", M: "М", O: "О", P: "Р", T: "Т", X: "Х" }; export const roomKey = (s) => (s || "") .replace(/\s+/g, "") .toUpperCase() .replace(/[ABCEHKMOPTX]/g, (c) => LATIN[c]) .replace(/\.$/, ""); /** Ordinary numbered rooms only: "129", "207Г". Distance learning "200(ДО)" is not a free room. */ export const isPhysicalRoom = (room) => /^(\d{2,4}[А-ЯA-Z]{0,2}|\d{2,4}\/\d{2,4}|[А-ЯA-Z]\d-\d{2})$/.test(room) && !/ДО$/.test(room); const isoFor = (day, month, year) => { const m = Number(month), y = m >= 8 ? Number(year) : Number(year) + 1; return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`; }; const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12; const tokens = (text) => (text || "") .toUpperCase() // "210 мк" → "210МК"; "129 и 130" stays two rooms. .replace(/(\d{2,4})\s+(?!И(?![А-ЯЁ]))([А-ЯЁ]{1,2})(?![А-ЯЁ])/gu, "$1$2") .match(ROOM_TOKEN) || []; /** * Rooms in a cell or room field. Dates, week lists and dated moves are taken out * first so "ауд. 432 14.10. -ауд. 333" gives room 432 and a move to 333 on 14 October. * "200 (ДО)" is distance learning and occupies no room. */ export function parseRooms(text, year) { let rest = (text || "").replace(/\u00a0/g, " "); const overrides = []; rest = rest.replace(OVERRIDE, (match, d, m, room) => { if (!validDate(d, m)) return match; if (year) overrides.push({ date: isoFor(d, m, year), room: roomKey(room) }); return " "; }); // "200 (ДО)" or "200ДО": distance learning. const distance = /\(\s*до\s*\)|\d\s*до(?![а-яё])/i.test(rest); rest = rest.replace(/\([^)]*\)?/g, " ").replace(DATE, " ").replace(WEEKS, " "); return { rooms: distance ? [] : [...new Set(tokens(rest).map(roomKey).filter((r) => !/ДО$/.test(r)))], overrides, distance, rest, }; } /** Every teacher and room named in a cell, not only the first one the parser picked. */ export function peopleAndRooms(lesson, year) { const teachers = new Set(); if (lesson.teacher) teachers.add(personName(lesson.teacher)); for (const m of (lesson.raw || "").matchAll(PERSON)) teachers.add(personName(m[0])); const cell = parseRooms(lesson.raw, year); const rooms = new Set(); // Rooms written after "ауд." / "а." and the "А1-15" style anywhere in the cell. for (const m of cell.rest.matchAll(MENTION)) for (const t of tokens(m[1])) rooms.add(roomKey(t)); for (const m of cell.rest.toUpperCase().matchAll(HYPHEN_ROOM)) rooms.add(roomKey(m[0])); // The parser's room field only counts when the cell really names that room // (it can pick "12" out of a date like "17.12" or "05" out of "В0-05"). const field = parseRooms(lesson.room, year); const cleanCell = cell.rest.toUpperCase().replace(/\s+/g, ""); for (const r of field.rooms) if ( ![...rooms].some((x) => x.endsWith(`-${r}`)) && new RegExp(`(^|[^\\dА-ЯA-Z-])${r}(?![\\dА-Я])`, "u").test(cleanCell) ) rooms.add(r); const overrides = [...cell.overrides, ...field.overrides].filter( (o, i, all) => all.findIndex((x) => x.date === o.date && x.room === o.room) === i, ); return { teachers: [...teachers], rooms: cell.distance || field.distance ? [] : [...rooms], overrides, distance: cell.distance || field.distance, }; } /** Rooms a lesson uses on a date: a dated replacement wins over the usual room. */ export function roomsOn(entry, date) { const moved = entry.overrides.filter((o) => o.date === date).map((o) => o.room); return moved.length ? moved : entry.rooms; } export function buildIndex(docs) { const entries = []; for (const doc of docs) for (const lesson of doc.schedule.lessons) entries.push({ doc, lesson, ...peopleAndRooms(lesson, doc.schedule.academicYear), }); const teachers = new Map(), rooms = new Map(); for (const e of entries) { for (const t of e.teachers) { const key = normalize(t), item = teachers.get(key) || { name: t, count: 0, faculties: new Set() }; item.count++; if (e.doc.schedule.source.faculty) item.faculties.add(e.doc.schedule.source.faculty); teachers.set(key, item); } for (const r of [...e.rooms, ...e.overrides.map((o) => o.room)]) rooms.set(r, (rooms.get(r) || 0) + 1); } return { entries, teachers, rooms }; } async function build(catalog) { const docs = [], failed = [], queue = [...catalog.files]; await Promise.all( Array.from({ length: 4 }, async () => { while (queue.length) { const file = queue.shift(); try { docs.push({ id: file.id, file, schedule: await scheduleFor(file) }); } catch (error) { failed.push({ id: file.id, name: file.name, error: error.message }); } } }), ); index = { catalogVersion: versionOf(catalog), year: catalog.year, builtAt: new Date().toISOString(), total: catalog.files.length, docs, failed, ...buildIndex(docs), }; return index; } // A catalog's documents and their dates; older cached catalogs have only the time of the crawl. const versionOf = (catalog) => catalog.version || catalog.updatedAt; function rebuild(catalog) { if (!building) building = build(catalog).finally(() => { building = null; }); return building; } /** * The current index; rebuilt in the background when a document of the catalog changes. A check * that finds the catalog as it was keeps the index, so its ETags, and the clients' caches, hold. */ export async function getIndex() { const catalog = await getCatalog(); if (!index || index.catalogVersion !== versionOf(catalog)) rebuild(catalog); if (!index) await building; return index; } /** The index of the catalog as it is now, waiting for the rebuild a change needs (the watcher). */ export async function syncIndex(catalog) { if (!index || index.catalogVersion !== versionOf(catalog)) { if (building) await building; if (!index || index.catalogVersion !== versionOf(catalog)) await rebuild(catalog); } return index; } export const indexStatus = () => index ? { documents: index.docs.length, total: index.total, failed: index.failed.length, builtAt: index.builtAt, rebuilding: Boolean(building), // Cells that look misread, listed by /api/diagnostics. problems: catalogDiagnostics(index).problems, } : { documents: 0, rebuilding: Boolean(building) }; const minutes = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; }; /** * Whether a lesson takes place on a date: "yes", "no", "maybe" (the week mark * is unreadable) or "unknown" (the document's semester is not in the week table). */ export function occurrence(entry, date, calendar, views = new Map()) { const { lesson, doc } = entry; if (lesson.date) return lesson.date === date ? "yes" : "no"; if (lesson.dates?.length) return lesson.dates.includes(date) ? "yes" : "no"; if (lesson.day !== weekday(date)) return "no"; const key = `${doc.id}|${date}`; if (!views.has(key)) views.set(key, semesterView(calendar, doc.schedule, date)); const view = views.get(key); if (!view) return "unknown"; const dates = lessonDates(lesson, view); if (dates) return dates.includes(date) ? "yes" : "no"; const week = view.weeks[weekIndexOf(view, date)]; if (week && lesson.variant !== "all" && (week.color === "white") !== (lesson.variant === "upper")) return "no"; return week ? "maybe" : "no"; } function coverage(idx, unknownDocs) { return { documents: idx.docs.length - unknownDocs.size, total: idx.total, failed: idx.failed.map((f) => f.name), withoutSemester: [...unknownDocs].map((id) => idx.docs.find((d) => d.id === id)?.file.name).filter(Boolean), year: idx.year, }; } export function search(idx, query) { const q = normalize(query); if (q.length < 2) return { teachers: [], rooms: [] }; const teachers = [...idx.teachers.entries()] .filter(([key]) => key.includes(q)) .map(([, t]) => ({ name: t.name, count: t.count, faculties: [...t.faculties] })) .sort((a, b) => Number(!normalize(a.name).startsWith(q)) - Number(!normalize(b.name).startsWith(q)) || b.count - a.count) .slice(0, 20); const roomQuery = roomKey(query); const rooms = [...idx.rooms.entries()] .filter(([room]) => room.startsWith(roomQuery)) .map(([room, count]) => ({ room, count })) .sort((a, b) => a.room.localeCompare(b.room, "ru", { numeric: true })) .slice(0, 20); return { teachers, rooms }; } /** One teacher's or room's lessons on a date, merged across groups and documents. */ export function dayOf(idx, calendar, { teacher, room }, date) { const views = new Map(), unknownDocs = new Set(); const want = teacher ? normalize(teacher) : null, wantRoom = room ? roomKey(room) : null; const merged = new Map(); for (const entry of idx.entries) { if (want && !entry.teachers.some((t) => normalize(t) === want)) continue; if ( wantRoom && !entry.rooms.includes(wantRoom) && !entry.overrides.some((o) => o.room === wantRoom) ) continue; const status = occurrence(entry, date, calendar, views); if (status === "unknown") unknownDocs.add(entry.doc.id); if (status !== "yes" && status !== "maybe") continue; const rooms = roomsOn(entry, date); if (wantRoom && !rooms.includes(wantRoom)) continue; const l = entry.lesson; const key = [l.start, normalize(l.subject), rooms.join(","), entry.teachers.join(",")].join("|"); const item = merged.get(key) || { start: l.start, end: l.end, subject: l.subject, type: l.type, teachers: entry.teachers, rooms, moved: rooms !== entry.rooms, distance: entry.distance, groups: [], documents: [], condition: l.condition, uncertain: false, }; for (const g of l.groups) if (!item.groups.includes(g)) item.groups.push(g); if (!item.documents.some((d) => d.id === entry.doc.id)) item.documents.push({ id: entry.doc.id, name: entry.doc.file.name }); if (status === "maybe") item.uncertain = true; merged.set(key, item); } return { date, lessons: [...merged.values()].sort((a, b) => a.start.localeCompare(b.start)), coverage: coverage(idx, unknownDocs), }; } /** Rooms with no lesson overlapping a bell pair on a date, by the published schedules. */ export function freeRooms(idx, calendar, date, pair) { const bell = calendar?.bells.find((b) => b.pair === pair); if (!bell) return null; const from = minutes(bell.start), to = minutes(bell.end); const views = new Map(), unknownDocs = new Set(), busy = new Map(), maybe = new Map(); const endOf = (l) => l.end ? minutes(l.end) : minutes(calendar.bells.find((b) => b.start === l.start)?.end || l.start) || minutes(l.start) + 80; for (const entry of idx.entries) { const rooms = roomsOn(entry, date).filter(isPhysicalRoom); if (!rooms.length) continue; const l = entry.lesson, start = minutes(l.start), end = Math.max(endOf(l), start + 45); if (end <= from || start >= to) continue; const status = occurrence(entry, date, calendar, views); if (status === "unknown") unknownDocs.add(entry.doc.id); const target = status === "yes" ? busy : status === "maybe" ? maybe : null; if (!target) continue; for (const r of rooms) target.set(r, [...(target.get(r) || []), { subject: l.subject, groups: l.groups, condition: l.condition }]); } const all = [...idx.rooms.keys()].filter(isPhysicalRoom); const free = all.filter((r) => !busy.has(r) && !maybe.has(r)).sort((a, b) => a.localeCompare(b, "ru", { numeric: true })); const groups = new Map(); for (const r of free) { const letter = r.match(/[А-ЯA-Z]+$/)?.[0] || r.match(/^[А-ЯA-Z]/)?.[0] || ""; groups.set(letter, [...(groups.get(letter) || []), r]); } return { date, pair, bell, free: [...groups.entries()] .sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b, "ru"))) .map(([letter, list]) => ({ letter, rooms: list })), freeCount: free.length, busyCount: busy.size, maybe: [...maybe.entries()].filter(([r]) => !busy.has(r)).map(([room, lessons]) => ({ room, lessons })), knownRooms: all.length, coverage: coverage(idx, unknownDocs), }; } export async function directoryCalendar() { try { return await getCalendar(); } catch { return null; } } export { todayIso }; export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация", "Защита курсовой работы"]; /** The index as it is now, without waiting for a first build (which this starts). */ export function readyIndex() { getIndex().catch(() => {}); return index; } /** * Dated session events of a group (exams, credits, consultations, coursework defences) from * every catalog document except `exceptDocId`, as index entries, so a timetable can show the * group's credits even when the university publishes them in a separate "Расписание зачетов". */ export function sessionEntriesOf(idx, group, faculty, exceptDocId) { const seen = new Map(); for (const entry of idx?.entries || []) { const { doc, lesson } = entry; if (doc.id === exceptDocId || !EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue; if (!lesson.date && !lesson.dates?.length) continue; if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue; const subgroup = lesson.subgroups?.find((s) => s.group === group)?.label || ""; const key = [lesson.date || lesson.dates.join(","), lesson.start, normalize(lesson.subject), lesson.type, subgroup].join("|"); if (!seen.has(key)) seen.set(key, entry); } return [...seen.values()]; } /** Dated exams, credits and consultations of a group across every document. */ export function examsOf(idx, group, faculty) { const seen = new Map(); for (const { doc, lesson, teachers, rooms } of idx.entries) { if (!EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue; if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue; const dates = lesson.date ? [lesson.date] : lesson.dates || []; for (const date of dates) { const key = [date, lesson.start, normalize(lesson.subject), lesson.type].join("|"); if (seen.has(key)) continue; seen.set(key, { date, start: lesson.start, end: lesson.end, type: lesson.type, subject: lesson.subject, teachers, rooms, document: { id: doc.id, name: doc.file.name, period: doc.schedule.period }, }); } } return [...seen.values()].sort( (a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start), ); } /** "23-БА", "23 ба" and "23ба" are the same group for searching. */ export const groupKey = (s) => (s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s\-–_.]/g, ""); /** * Documents that only add to a group's timetable: their credits, exams and consultations reach * the student through the session countdown (/api/exams), so the group picker offers them only * when the group has nothing else. */ const SUPPLEMENTS = new Set(["Зачёты", "Экзаменационная сессия"]); /** The last date of a document whose every lesson is dated ("Введение в высшее образование"), else null. */ function lastDate(schedule) { const dates = schedule.lessons.flatMap((l) => (l.date ? [l.date] : l.dates || [])); return dates.length && schedule.lessons.every((l) => l.date || l.dates?.length) ? dates.sort().at(-1) : null; } /** * Every group of every document, with the catalog place it came from. A group with a main * timetable is not offered its supplements, nor dated documents that are already over. */ export function groupEntries(idx, today = todayIso()) { const list = []; for (const { file, schedule } of idx.docs) for (const group of schedule.groups) list.push({ group, key: groupKey(group), fileId: file.id, file: file.name, faculty: file.faculty || schedule.source.faculty || "", course: file.course || schedule.source.course || "", form: file.form || "", period: schedule.period || "", modified: file.modified || null, over: (lastDate(schedule) ?? "9999") < today, }); const secondary = (e) => SUPPLEMENTS.has(e.period) || e.over; const main = new Set(list.filter((e) => !secondary(e)).map((e) => e.key)); return list.filter((e) => !secondary(e) || !main.has(e.key)).map(({ over, ...e }) => e); } const count = (items, pick) => { const map = new Map(); for (const item of items) { const value = pick(item); if (value) map.set(value, (map.get(value) || 0) + 1); } return [...map.entries()] .map(([name, n]) => ({ name, count: n })) .sort((a, b) => a.name.localeCompare(b.name, "ru", { numeric: true })); }; /** * Groups matching a query and/or catalog filters. The query ignores filters on * purpose: a student typing their group must find it in any faculty. */ export function findGroups(idx, { q = "", faculty = "", course = "", form = "", today } = {}) { const all = groupEntries(idx, today); const key = groupKey(q); let results; if (key.length >= 2) { const rank = (e) => (e.key === key ? 0 : e.key.startsWith(key) ? 1 : 2); results = all .filter((e) => e.key.includes(key)) .sort( (a, b) => rank(a) - rank(b) || a.group.localeCompare(b.group, "ru", { numeric: true }) || a.period.localeCompare(b.period, "ru"), ) .slice(0, 50); } else if (faculty || course || form) { results = all .filter( (e) => (!faculty || e.faculty === faculty) && (!course || e.course === course) && (!form || e.form === form), ) .sort((a, b) => a.group.localeCompare(b.group, "ru", { numeric: true })) .slice(0, 300); } else results = []; const inFaculty = all.filter((e) => !faculty || e.faculty === faculty); return { results, facets: { faculties: count(all, (e) => e.faculty), courses: count(inFaculty, (e) => e.course), forms: count(inFaculty, (e) => e.form), }, total: all.length, year: idx.year, }; }