Files
psu-schedule/server/directory.js

478 lines
18 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.
// 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 { 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*([^\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 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({ name: file.name, error: error.message });
}
}
}),
);
index = {
catalogAt: catalog.updatedAt,
year: catalog.year,
builtAt: new Date().toISOString(),
total: catalog.files.length,
docs,
failed,
...buildIndex(docs),
};
return index;
}
/** The current index; rebuilt in the background when the catalog changes. */
export async function getIndex() {
const catalog = await getCatalog();
if ((!index || index.catalogAt !== catalog.updatedAt) && !building)
building = build(catalog).finally(() => {
building = null;
});
if (!index) await building;
return index;
}
export const indexStatus = () =>
index
? {
documents: index.docs.length,
total: index.total,
failed: index.failed.length,
builtAt: index.builtAt,
rebuilding: Boolean(building),
}
: { 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 = ["Экзамен", "Зачёт", "Консультация"];
/** 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,
};
}