initial commit
This commit is contained in:
456
server/directory.js
Normal file
456
server/directory.js
Normal file
@@ -0,0 +1,456 @@
|
||||
// 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, "");
|
||||
|
||||
/** Every group of every document, with the catalog place it came from. */
|
||||
export function groupEntries(idx) {
|
||||
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,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
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 = "" } = {}) {
|
||||
const all = groupEntries(idx);
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user