Files
psu-schedule/server/directory.js

540 lines
20 KiB
JavaScript
Raw Permalink 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.
// 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 { PERSON, personName, roomKey, tokens, roomMoves } from "./rooms.js";
import {
semesterView,
lessonDates,
todayIso,
weekday,
weekIndexOf,
} from "../shared/weeks.js";
import { planFor as planForWeek } from "../shared/lessons.js";
let index = null,
building = null;
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*([^\n(]{0,40})/giu;
const HYPHEN_ROOM = /[А-ЯA-Z]\d-\d{2}/gu;
export const normalize = (s) =>
(s || "").toLowerCase().replace(/ё/g, "е").replace(/[\s.]/g, "");
export { personName, roomKey };
/** 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);
/**
* 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) {
const moved = roomMoves((text || "").replace(/\u00a0/g, " "), year);
const overrides = moved.moves;
let rest = moved.rest;
// "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,
);
// Rooms and teachers by week ("1-9н. 502н / с 10н. – 359"): all of them belong to the lesson.
const plan = lesson.plan || [];
for (const p of plan) {
for (const r of p.rooms) rooms.add(r);
for (const t of p.teachers) teachers.add(personName(t));
}
return {
teachers: [...teachers],
rooms: cell.distance || field.distance ? [] : [...rooms],
overrides,
plan,
distance: cell.distance || field.distance,
};
}
/** The lesson's plan entries for a week `{n, color}`: by its number, its colour or both. */
const planFor = (entry, week) => planForWeek(entry.plan, week);
/**
* Rooms a lesson uses on a date: a dated move wins, then the room for that week ("1-9н. 502н",
* "бел.н.504н/зел.н.524н"), then the lesson's rooms.
*/
export function roomsOn(entry, date, week = null) {
const moved = entry.overrides.filter((o) => o.date === date).map((o) => o.room);
if (moved.length) return moved;
const planned = [...new Set(planFor(entry, week).flatMap((p) => p.rooms))];
if (planned.length) return planned;
// A week the plan does not name: not the rooms it names only for other weeks.
if (week && entry.plan?.length) {
const only = new Set(entry.plan.flatMap((p) => p.rooms));
return entry.rooms.filter((r) => !only.has(r));
}
return entry.rooms;
}
/** Whether the lesson moves to another room on that date. */
export const movedOn = (entry, date) => entry.overrides.some((o) => o.date === date);
/** Teachers of a lesson in a week: "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э." names one each. */
export function teachersOn(entry, week = null) {
const planned = [...new Set(planFor(entry, week).flatMap((p) => p.teachers))];
if (planned.length) return planned;
if (week && entry.plan?.length) {
const only = new Set(entry.plan.flatMap((p) => p.teachers));
const usual = entry.teachers.filter((t) => !only.has(t));
if (usual.length) return usual;
}
return entry.teachers;
}
/** The week a date falls in for an entry's document, `{n, color}` or null. */
export function weekFor(entry, date, calendar, views = new Map()) {
const key = `${entry.doc.id}|${date}`;
if (!views.has(key)) views.set(key, semesterView(calendar, entry.doc.schedule, date));
const view = views.get(key);
return view ? view.weeks[weekIndexOf(view, date)] || null : null;
}
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 week = weekFor(entry, date, calendar, views);
const rooms = roomsOn(entry, date, week);
const teachers = teachersOn(entry, week);
if (wantRoom && !rooms.includes(wantRoom)) continue;
if (want && !teachers.some((t) => normalize(t) === want)) continue;
const l = entry.lesson;
const key = [l.start, normalize(l.subject), rooms.join(","), teachers.join(",")].join("|");
const item = merged.get(key) || {
start: l.start,
end: l.end,
subject: l.subject,
type: l.type,
teachers,
rooms,
moved: movedOn(entry, date),
place: l.place || null,
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, weekFor(entry, date, calendar, views)).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,
};
}