// Towns of a lesson, as Word cells name them. Most lessons are in Novopolotsk; some faculties // teach in Polotsk, and a cell says when a lesson or a whole day is in the other town: // "Занятия в Полоцке" over a day's first row (that day, the column's lessons are in Polotsk), // "Новополоцк" or "г.Новополоцк" on a line of its own, "246(Новополоцк)" after a room. // Towns a cell may name without "г.": a bare capitalised word is otherwise a subject. const TOWNS = "Новополоцк|Полоцк|Междуречье|Витебск|Минск"; const TOWN = String.raw`(?:г\.\s*([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)|(${TOWNS})е?)`; // "Занятия в Полоцке", "Занятия проходят в г. Новополоцк": the rest of the line, if any, is a lesson. const NOTE = new RegExp( String.raw`^[Зз]анятия\s+(?:(?:проход[яи]т|будут\s+проходить)\s+)?в\s+(?:г\.\s*)?([А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?)(?=$|[\s.,;:!–—-])[.!]?`, "u", ); // A line that starts with the town: "г.Новополоцк", or "г. Полоцк, Дом офицеров" with the venue. const LINE = new RegExp(String.raw`^\(?${TOWN}\)?(?:[.,]?$|(,\s*\S.*))`, "u"); // "246(Новополоцк)", "121 (г.Новополоцк)". const AFTER_ROOM = new RegExp(String.raw`(?<=\d[А-ЯЁа-яё]{0,2})\s*\(${TOWN}\)`, "u"); /** "Полоцке" → "Полоцк", "Витебске" → "Витебск"; "Междуречье" stays. */ export const townName = (word) => word.replace(/(?<=(?:к|ст))е$/u, ""); /** * A note that a day is in another town, on a line of the cell: `{text, place, rest}`, the * cell without the note in `rest`; null when the cell has none. */ export function placeNote(text) { const lines = (text || "").split("\n"); for (const [i, line] of lines.entries()) { const m = line.trim().match(NOTE); if (!m) continue; const rest = [...lines.slice(0, i), line.trim().slice(m[0].length).trim(), ...lines.slice(i + 1)] .filter((l) => l.trim()) .join("\n"); return { text: m[0].replace(/[.!]$/, "").replace(/\s+/g, " "), place: townName(m[1]), rest }; } return null; } /** The town a lesson's own text names, and the text without it: `{place, rest}`. */ export function lessonPlace(text) { let place = null; const rest = (text || "") .split("\n") .filter((line) => { const m = line.trim().match(LINE); if (m && !place) place = townName(m[1] || m[2]); // The town alone goes; a venue after it stays in the text. return !m || m[3]; }) .join("\n") .replace(AFTER_ROOM, (_, named, town) => { place ||= townName(named || town); return ""; }); return { place, rest }; } /** * The town of a lesson from the notes of its day: for every group of the lesson, a note for its * subgroup or the whole group, or — for a lesson of the whole group — notes in every subgroup * that name the same town. `labelsOf(group)`: the group's subgroup labels. */ export function placeOf(lesson, places, labelsOf) { let found = null; for (const group of lesson.groups) { const notes = places.filter( (p) => p.day === lesson.day && (!p.date || p.date === lesson.date) && p.groups.includes(group), ); if (!notes.length) return null; const label = lesson.subgroups.find((s) => s.group === group)?.label; const own = (p) => p.subgroups.filter((s) => s.group === group).map((s) => s.label); let place = notes.find((p) => !own(p).length || (label && own(p).includes(label)))?.place; if (!place && !label) { const towns = new Map(notes.flatMap((p) => own(p).map((l) => [l, p.place]))); const labels = labelsOf(group); if (labels.length && labels.every((l) => towns.has(l)) && new Set(towns.values()).size === 1) place = towns.get(labels[0]); } if (!place || (found && found !== place)) return null; found = place; } return found; }