diff --git a/README.md b/README.md index 120d4b5..829cf59 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,29 @@ npm run sync Поля предмета, преподавателя и аудитории извлекаются по типовым обозначениям; сложные многострочные записи могут содержать несколько преподавателей/аудиторий. Поэтому полный текст всегда доступен в деталях. Если в ячейке явно указано особое время, например `16.50` для кураторского часа, `9.00`, `18.35.`, интервал `11:10-11:30` или несколько времён (`17.00 и 18.35`, `18.35+20.10` — тогда это отдельные занятия), оно имеет приоритет; окончание не выдумывается. Дата в начале ячейки (`13.10`, `3.12`, `24.11.`, `12.09.26г.`) делает запись разовой. Запись вида `20.10` может быть и датой, и началом восьмой пары: если такая дата приходится на другой день недели, чем строка, или после неё идёт пометка недель, это время. +**Аудитории и преподаватели по неделям и датам** (`server/rooms.js`): +- **Перенос на дату.** «412н 26.10.2026‑333», «402н (13.10‑ауд.510н)», «14.10. ‑ауд. 333» — в этот день занятие идёт в другой аудитории, в остальные дни — в обычной. Год в дате необязателен. Расписание по датам отдаёт аудиторию дня с пометкой `roomMoved`. +- **План по неделям.** Строка под занятием, где есть только недели, аудитории или преподаватели, продолжает это занятие, а не становится новым. Например: «1‑9н. 502н / с 10н. – 359», «1‑13н‑332, 14‑17н.‑502н», «2‑9н.‑Клос О.Ю., 10‑14н.‑Конторов В.Э. 319Д». Аудитория в конце такой строки относится ко всем её частям. +- **План по цвету недели.** «бел.н.504н/зел.н.524н», «б.н.301Г/з.н.317Д». +- **Как отдаётся.** План лежит у занятия в поле `plan`. Расписание по датам и справочник «Кто где» берут аудиторию и преподавателя той недели. В неделе, которой нет в плане, не показываются аудитории и преподаватели, указанные только для других недель. + +**Ещё форматы ячеек:** +- **Вид без предмета.** Строка «7,9,11н. – (п.з.) Савицкая К.Д. … 8,12,14н. – (п.з.) …» — тот же предмет, что выше, в другие недели, с этим видом. +- **Разный вид по неделям.** «1‑11н. ‑ л., 12‑14 – п.з. Защита населения…» — лекция на одни недели, практика на другие. +- **Дисциплины по выбору и специализации.** + - Заголовок «Дисциплины по выбору:» или «Дисциплина специализации: 4‑14 н. …» — не занятие, и он не скрывает недели. + - Строка «Социология 1‑10 н. (л.) … 11‑16 н. (п.з.) …» с неделями после названия становится занятиями по видам. + - Строка с предметом и аудиторией над строками по видам — заголовок: её аудитория переходит к занятиям под ней. +- **Совмещённый вид.** «(л./п.з.)» и «(л. и п.з.)» — вид «Лекция и практика». +- **Подгруппа в тексте.** «12н.2п/гр. Экономика…» — занятие одной подгруппы. +- **Ячейка из одной точки** — не занятие. +- **Время в начале ячейки:** + - интервал с дефисом «17:00‑17:20‑информационный час»; + - заголовок со временем «18.35+20.10 Новополоцк:» передаёт время занятиям под ним. +- **Длинная запись под заголовком заглавными** («ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …») называется этим заголовком. + +**Группы с направлением в скобках** — «24‑ИЯ (РА)и‑1» … «24‑ИЯ (РА)и‑6» — разные группы. Они пишутся, как у университета, с пробелом перед скобкой. До версии парсера 13 они сливались в одну «24‑ИЯ(РА)» со всеми занятиями трёх групп. Студентам с такой сохранённой группой нужно выбрать свою группу заново. На старое имя `/api/timetable` отвечает `404 group_not_found` и в тексте ошибки называет новые группы. Имя без пробела («24‑ИЯ(РА)и‑1») по‑прежнему находит группу. + Для старых `.doc` на сервере нужен LibreOffice Writer (`soffice` в PATH или `LIBREOFFICE_BIN`). Конвертация может изменить сложное форматирование, результат нужно сверить. Реальные документы выбранного источника — `.docx`; поддержка `.doc` проверяется отдельно на образце, преобразованном LibreOffice. PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание. diff --git a/server/diagnostics.js b/server/diagnostics.js index ba3f86c..9a8cd44 100644 --- a/server/diagnostics.js +++ b/server/diagnostics.js @@ -1,4 +1,5 @@ import { parseWeekRanges, weekday, DATE_LIST } from "../shared/weeks.js"; +import { roomMoves } from "./rooms.js"; // The parser never guesses: a cell it cannot read becomes a lesson "every week" or keeps its // whole text as the subject. These checks find such cells after parsing, so a new format or a @@ -53,7 +54,9 @@ export function diagnose(schedule) { add("weeks_out_of_range", lesson, { condition: lesson.condition }); DATE_LIST.lastIndex = 0; } - if (!dated && !lesson.condition && (MONTHS.test(raw) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(raw))) + // A move to another room on a date ("412н 26.10.2026-333") is read, not a lesson date. + const undated = roomMoves(raw).rest; + if (!dated && !lesson.condition && (MONTHS.test(undated) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(undated))) add("dates_unread", lesson); for (const date of lesson.date ? [lesson.date] : lesson.dates || []) { if (weekday(date) !== lesson.day) add("date_weekday", lesson, { date }); diff --git a/server/directory.js b/server/directory.js index d5957c0..5dd41ea 100644 --- a/server/directory.js +++ b/server/directory.js @@ -4,6 +4,7 @@ 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, @@ -15,15 +16,6 @@ import { 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; @@ -31,46 +23,21 @@ 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(/\.$/, ""); +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); -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 " "; - }); + 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, " "); @@ -105,17 +72,61 @@ export function peopleAndRooms(lesson, year) { 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, }; } -/** Rooms a lesson uses on a date: a dated replacement wins over the usual room. */ -export function roomsOn(entry, date) { + +const inWeeks = (ranges, n) => !ranges || (n != null && ranges.some(([a, b]) => n >= a && (b === null || n <= b))); +/** The lesson's plan entries for a week `{n, color}`: by its number, its colour or both. */ +const planFor = (entry, week) => + week && entry.plan?.length ? entry.plan.filter((p) => inWeeks(p.weeks, week.n) && (!p.color || p.color === week.color)) : []; + +/** + * 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); - return moved.length ? moved : entry.rooms; + 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) { @@ -285,18 +296,21 @@ export function dayOf(idx, calendar, { teacher, room }, date) { 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); + 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(","), entry.teachers.join(",")].join("|"); + 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: entry.teachers, + teachers, rooms, - moved: rooms !== entry.rooms, + moved: movedOn(entry, date), distance: entry.distance, groups: [], documents: [], @@ -329,7 +343,7 @@ export function freeRooms(idx, calendar, date, pair) { 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); + const rooms = roomsOn(entry, date, weekFor(entry, date, calendar, views)).filter(isPhysicalRoom); if (!rooms.length) continue; const l = entry.lesson, start = minutes(l.start), diff --git a/server/parser.js b/server/parser.js index e213952..790b89f 100644 --- a/server/parser.js +++ b/server/parser.js @@ -7,8 +7,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js"; +import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js"; const run = promisify(execFile); -export const PARSER_VERSION = 12; +export const PARSER_VERSION = 13; const days = [ "понедельник", "вторник", @@ -24,19 +25,22 @@ const clean = (value) => .replace(/[\t ]+/g, " ") .replace(/\s*\n\s*/g, "\n") .trim(); -// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "25-ИЯ(РА)", -// "23-ИНС-1". Header cells also carry practice dates ("02.11-22.11-производственная практика", -// "09.11 по 06.12"): a number right after a date's dot or another digit is never a group. +// A group is a two-digit year and a capitalised abbreviation: "23-БА", "24-ДОи", "23-ИНС-1", +// "24-ИЯ (РА)и-1" (a specialisation in brackets, then the group's letter and number). Header +// cells also carry practice dates ("02.11-22.11-производственная практика", "09.11 по 06.12"): +// a number right after a date's dot or another digit is never a group. Written as the university +// writes it: "24-ИЯ (РА)и-1", with the space before the bracket. export const groupsIn = (value) => [ ...value.matchAll( - /(? m[0] .replace(/\s+/g, "") .replace(/–/g, "-") - .replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2"), + .replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2") + .replace(/(\S)\(/, "$1 ("), ); function timesIn(value) { const times = [...value.matchAll(/\b([0-2]?\d)[.:]([0-5]\d)\b/g)].map( @@ -73,15 +77,20 @@ export function details(raw) { // Correspondence sessions: "Экзамен по учебной дисциплине «Базы данных» Скуковская А.А. ауд.219Д". const named = rest.match(/^по\s+(?:учебной\s+)?дисциплине\s*«([^»]+)»\s*/i); if (named) rest = named[1].trim() + " " + rest.slice(named[0].length); - const kind = rest.match( - /\((уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i, - ); + // "Дисциплина по выбору: Социология": the elective's label is no part of its name. + rest = rest.replace(/^дисциплин[аы]\s+(?:по\s+выбору|с[пц]е[цш]иализаци[ийя])\s*:\s*/iu, ""); + // "12н.2п/гр. Экономика…": the lesson of one subgroup, named in the cell. + const own = rest.match(/^(\d)\s*п\.?\s*\/?\s*гр?\.?\s*/i); + if (own) rest = rest.slice(own[0].length); + const kind = rest.match(KIND); // Whole words only: "консультирования" in a subject name is not a consultation. const sessionWord = (leading?.[1] || rest.match(/(?= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading; const defence = /^защита/i.test(leading?.[1] || ""); const token = - kind?.[1].replace(/[.\s]/g, "").toLowerCase() || + kind?.[1].replace(/[.\s/]/g, "").toLowerCase() || (defence ? "защ" : undefined) || (sessionWord.startsWith("экз") ? "экз" @@ -110,7 +123,9 @@ export function details(raw) { teacher: person?.[0] || "", room: room?.[1].trim().replace(/\s+/g, "") || "", type: - token === "устл" + token === "лпз" || token === "липз" + ? "Лекция и практика" + : token === "устл" ? "Установочная лекция" : token === "устпз" ? "Установочное занятие" @@ -132,11 +147,97 @@ export function details(raw) { ? "Консультация" : "Занятие", condition, + ...(own && { ownSubgroup: Number(own[1]) }), }; } -// Split only at an explicit new week condition. Keep unfamiliar wording intact. -function alternatives(raw) { - return raw.split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)})`, "i")).filter(Boolean); +// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)". +const KIND = + /\((л\.?\s*(?:\/|и)\s*п\.?\s*з\.?|уст\.?\s*л\.?|уст\.?\s*п\.?\s*з\.?|л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i; +// Split only at an explicit new week condition. Keep unfamiliar wording intact. A line of an +// elective's choice may put its weeks after the subject ("Социология 1-10 н. (л.) …"): it is +// a lesson of its own, read with the weeks first. +const SUBJECT_THEN_WEEKS = /^([А-ЯЁ][а-яё]+(?:[ -][а-яё]+){0,4})\s+((?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)\s*(?=\()/u; +// "Дисциплина специализации: 4-14 н. Преступления…": the heading hides the weeks. +const HEADING = /^[^\n:]{3,40}:\s*(?=(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н)/u; +// "… (л.) Шидловский С.О. 11-16 н. (п.з.) Лапацуева А.С.": after a teacher, other weeks of +// another kind begin on the same line. +const NEXT_KIND = /(?<=[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\.)\s+(?=(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s*\()/u; +export function alternatives(raw) { + return raw + // Lookaheads without capturing groups: split() would return what they capture. + .split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)}|${SUBJECT_THEN_WEEKS.source.slice(1).replace(/(? part.replace(HEADING, "").replace(SUBJECT_THEN_WEEKS, "$2 $1 ")) + .flatMap((part) => part.split(NEXT_KIND)) + .reduce( + (state, part) => { + // "Дисциплины по выбору:" heads the lessons below; it is none itself. A heading with + // times ("18.35+20.10 Новополоцк:") gives those times to the lessons under it. + if (/^[^\n]{1,60}:\s*$/.test(part.trim())) state.times = part.match(HEADING_TIMES)?.[0] || state.times; + else state.parts.push(state.times ? `${state.times} ${part}` : part); + return state; + }, + { parts: [], times: "" }, + ).parts; +} +const HEADING_TIMES = /^[0-2]?\d[.:][0-5]\d(?:\s*(?:\+|и|,)\s*[0-2]?\d[.:][0-5]\d)*/u; +// "1-11н. - л., 12-14 – п.з. Защита населения… Дмитриченко Н.С.": one kind for some weeks, +// another for the others. +const WEEKS_TEXT = String.raw`(?:с\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)?`; +const KIND_TEXT = String.raw`л|п\.?\s*з|л\.?\s*з|лаб|пр`; +const BY_KIND = new RegExp( + String.raw`^(${WEEKS_TEXT})\s*[-–—]\s*(${KIND_TEXT})\.?\s*,\s*(${WEEKS_TEXT})\s*[-–—]\s*(${KIND_TEXT})\.?\s+([\s\S]+)$`, + "iu", +); +function byKind(text) { + const m = text.match(BY_KIND); + if (!m) return [text]; + const weeks = (w) => (/н/i.test(w) ? w.trim() : `${w.trim()}н.`); + // The kind goes right after the subject, before the teacher and the room. + const rest = m[5]; + const cut = [rest.search(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u), rest.search(/(?:ауд\.?|(? i > 0) + .reduce((a, b) => Math.min(a, b), rest.length); + const at = (kind) => `${rest.slice(0, cut).trim()} (${kind}.) ${rest.slice(cut)}`.trim(); + return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`]; +} + +/** + * A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind, + * teacher and room are read from it) and the plan. A line that names only rooms or teachers by week + * ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson + * above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject + * ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in + * other weeks. A stray "." is no lesson. + */ +export function entries(raw) { + const out = []; + for (const alternative of alternatives(raw).flatMap(byKind)) { + if (!/[\p{L}\d]/u.test(alternative)) continue; + const prev = out.at(-1); + if (prev && onlyPlace(alternative)) { + if (KIND.test(alternative)) { + const head = details(prev.main); + // "Дисциплина по выбору: Социология ауд. С0-03" over lines by kind: a heading, not a + // lesson; its room goes to the lessons under it. + if (!KIND.test(prev.main) && !head.teacher && !prev.plan.length) prev.heading = true; + for (const part of partsByWeek(alternative)) { + const mark = part.match(WEEK_MARK)?.[0] || ""; + const rest = part.slice(mark.length); + const room = prev.heading && head.room && !/\d{2,}/.test(rest) ? ` ауд. ${head.room}` : ""; + const text = `${mark} ${head.subject} ${rest}${room}`.replace(/[ \t]+/g, " ").trim(); + out.push({ text, main: text, plan: [] }); + } + } else { + prev.text += "\n" + alternative; + prev.plan.push(...weekPlan(alternative)); + } + continue; + } + // "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones. + out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) }); + } + return out.filter((e) => !e.heading); } export function readDocx(buffer) { let zip; @@ -250,7 +351,7 @@ export function leadingDate(text, yearStart) { */ export function leadingTime(text, loose = false) { const hm = (h, m) => `${h.padStart(2, "0")}:${m}`; - const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s)/); + const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s|[-–—])/); if (range && Number(range[1]) <= 23 && Number(range[3]) <= 23) return { starts: [hm(range[1], range[2])], end: hm(range[3], range[4]), length: range[0].length }; const T = String.raw`[0-2]?\d[.:][0-5]\d`; @@ -447,10 +548,11 @@ export function parseDocx(buffer, source = {}) { const spansBoth = cell.merge === "restart" && rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue"; - for (const alternative of alternatives(raw)) { - // "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject. + for (const { text: whole, main: alternative, plan } of entries(raw)) { + // "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the + // subject, nor is a move to another room on a date ("412н 26.10.2026-333"). const read = (text) => { - const data = details(text.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim()); + const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim()); if (credits && data.type === "Занятие") data.type = "Зачёт"; return data; }; @@ -483,6 +585,10 @@ export function parseDocx(buffer, source = {}) { group: r.group, label: `Подгруппа ${cell.col - r.col + 1}`, })); + // A subgroup named in the cell ("2п/гр.") when the column does not tell one. + const own = data.ownSubgroup; + delete data.ownSubgroup; + if (own && !subgroups.length) subgroups.push(...targetGroups.map((group) => ({ group, label: `Подгруппа ${own}` }))); // "17.00 и 18.35 Русский язык": one lesson at each time. for (const [n, start] of (lead?.starts || [null]).entries()) lessons.push({ @@ -511,7 +617,9 @@ export function parseDocx(buffer, source = {}) { !["auto", "FFFFFF"].includes(cell.color.toUpperCase()) ? cell.color : null, - raw: alternative, + raw: whole, + // Rooms and teachers by week, or by the week's colour. + ...(plan.length && { plan }), }); } }; diff --git a/server/rooms.js b/server/rooms.js new file mode 100644 index 0000000..7cfda59 --- /dev/null +++ b/server/rooms.js @@ -0,0 +1,114 @@ +import { parseWeekRanges } from "../shared/weeks.js"; + +// Rooms and teachers as Word cells write them, shared by the parser, the directory and the +// diagnostics: room tokens, dated moves ("26.10.2026-333") and plans by week ("1-9н. 502н, +// с 10н. – 359", "бел.н.504н/зел.н.524н", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э."). + +export const PERSON = /[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./gu; +export const personName = (s) => s.replace(/\s+/g, " ").replace(/\.\s+/g, ".").trim(); +// "129", "207Г", "202МК", "504Н", and the "В0-03" style. Rooms have at least two digits. +export 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мк", "26.10.2026-333": +// on that date the lesson moves. +export const ROOM_MOVE = new RegExp( + `(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{4}|\\d{2})(?!\\d))?\\.?\\s*(?:[-–]\\s*(?:ауд\\.?|а\\.)?|(?:ауд\\.?|а\\.))\\s*(${ROOM_TEXT})`, + "giu", +); + +// 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(/\.$/, ""); + +export 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) || []; + +const validDate = (d, m) => Number(d) >= 1 && Number(d) <= 31 && Number(m) >= 1 && Number(m) <= 12; +const isoFor = (day, month, year, explicit) => { + const m = Number(month), + y = explicit ? Number(explicit.length === 2 ? `20${explicit}` : explicit) : m >= 8 ? Number(year) : Number(year) + 1; + return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +}; + +/** Dated moves in a text and the text without them: `{moves: [{date, room}], rest}`. */ +export function roomMoves(text, year) { + const moves = []; + const rest = (text || "").replace(ROOM_MOVE, (match, d, m, y, room) => { + if (!validDate(d, m)) return match; + if (year || y) moves.push({ date: isoFor(d, m, year, y), room: roomKey(room) }); + return " "; + }).replace(/\(\s*\)/g, " "); + // "402н (13.10-ауд.510н)" leaves "402н ( )": the brackets go with the move. + return { moves, rest }; +} + +// A week mark opening a part of a line: "1-9н.", "с 10н", "14-17н.", "7,9,11н.". +const WEEK_MARK = /(?<=^|[\s,;])(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?(?![а-яё])\.?/giu; + +/** A line cut where each week mark begins: "1-13н-332, 14-17н.-502н" → two parts. */ +export function partsByWeek(text) { + const starts = [...text.matchAll(WEEK_MARK)].map((m) => m.index); + if (!starts.length || starts[0] > 0) starts.unshift(0); + return starts.map((start, i) => text.slice(start, starts[i + 1]).trim()).filter(Boolean); +} +const PART_MARK = /^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/iu; +// "бел.н.504н/зел.н.524н", "б.н.301Г/з.н.317Д": a room for white weeks and one for green ones. +const BY_COLOR = /б(?:ел)?\.?\s*н(?:ед)?\.?\s*([^/\s]+)\s*\/\s*з(?:ел)?\.?\s*н(?:ед)?\.?\s*([^\s,;]+)/iu; + +const roomsIn = (text) => + tokens(text.replace(PERSON, " ").replace(/\([^)]*\)/g, " ")) + .map(roomKey) + .filter((r) => !/ДО$/.test(r)); + +/** + * Rooms and teachers that depend on the week, from a line of a cell: entries + * `{weeks: [[a, b]] | null, color: "white" | "green" | null, rooms, teachers}`. Empty when the + * line names none of them by week. + */ +export function weekPlan(text) { + const plan = []; + for (const part of partsByWeek(text || "")) { + const mark = part.match(PART_MARK)?.[0] || ""; + const weeks = mark ? parseWeekRanges(mark) : null; + if (mark && !weeks) continue; + const rest = part.slice(mark.length); + const teachers = [...rest.matchAll(PERSON)].map((m) => personName(m[0])); + const color = rest.match(BY_COLOR); + if (color) { + plan.push({ weeks, color: "white", rooms: roomsIn(color[1]), teachers }); + plan.push({ weeks, color: "green", rooms: roomsIn(color[2]), teachers }); + } else if (mark) { + const rooms = roomsIn(rest); + if (rooms.length || teachers.length) plan.push({ weeks, color: null, rooms, teachers }); + } + } + // "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д": teachers by week, then the room of them all. + const last = plan.at(-1); + if (plan.length > 1 && last.rooms.length && plan.slice(0, -1).every((p) => !p.rooms.length && p.teachers.length && !p.color)) + for (const p of plan.slice(0, -1)) p.rooms = [...last.rooms]; + return plan; +} + +/** + * Whether a line names only rooms, teachers and weeks, with no subject: "1-9н. 502н", + * "с 10н. – 533н", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д". It continues the lesson above. + */ +export function onlyPlace(text) { + const left = (text || "") + .replace(PERSON, " ") + .replace(BY_COLOR, " ") + .replace(/(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?/giu, " ") + .replace(/ауд\.?|а\./giu, " ") + .replace(/[А-ЯA-Z]\d-\d{2}|\d{2,4}\s?[А-Яа-я]{0,2}(?![а-яё])/gu, " "); + // A word, or an abbreviation of a subject ("РБ"): not only a place. + return !/[А-Яа-яЁё]{3,}|[А-ЯЁ]{2,}/u.test(left); +} diff --git a/server/timetable.js b/server/timetable.js index bdf914e..1caff80 100644 --- a/server/timetable.js +++ b/server/timetable.js @@ -2,7 +2,7 @@ // third-party clients use it instead of re-implementing the white/green week rules. import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js"; import { lessonKey, subgroupsOf } from "../shared/lessons.js"; -import { occurrence, peopleAndRooms, roomsOn } from "./directory.js"; +import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js"; import { apiError } from "./errors.js"; const MAX_DAYS = 62; @@ -10,9 +10,10 @@ const MAX_DAYS = 62; const MAX_SEMESTER_DAYS = 200; const DAY_MS = 86_400_000; -function lessonOut(lesson, group, people, date, status, calendar) { +// On a date: the room and teacher of that day and week ("1-9н. 502н", a move on 26.10). +function lessonOut(lesson, group, people, date, status, calendar, week = null) { const bell = calendar?.bells.find((b) => b.start === lesson.start); - const rooms = date ? roomsOn(people, date) : people.rooms; + const rooms = date ? roomsOn(people, date, week) : people.rooms; return { key: lessonKey(lesson, group), start: lesson.start, @@ -20,9 +21,9 @@ function lessonOut(lesson, group, people, date, status, calendar) { pair: bell?.pair ?? null, subject: lesson.subject, type: lesson.type, - teachers: people.teachers, + teachers: date ? teachersOn(people, week) : people.teachers, rooms, - roomMoved: Boolean(date && rooms !== people.rooms), + roomMoved: Boolean(date && movedOn(people, date)), distance: people.distance, condition: lesson.condition, variant: lesson.variant, @@ -37,9 +38,23 @@ function lessonOut(lesson, group, people, date, status, calendar) { * days: one entry per date from `from`, with the lessons that take place that day. * weekly: the Word rows by weekday, for when dates cannot be calculated. */ +const groupKey = (g) => g.toLowerCase().replace(/[\s\-–]/g, ""); + export function timetable({ file, schedule, calendar, group, from, days, range, today, session: extra = [] }) { if (!schedule.groups.includes(group)) { - throw apiError(404, "group_not_found", `В документе нет группы ${group}.`); + // The same group written another way: "24-ИЯ(РА)и-1" for "24-ИЯ (РА)и-1". + const same = schedule.groups.find((g) => groupKey(g) === groupKey(group)); + // A name parser 13 split into groups: a client may still keep "24-ИЯ(РА)". + const split = schedule.groups.filter((g) => groupKey(g).startsWith(groupKey(group))); + if (same) group = same; + else + throw apiError( + 404, + "group_not_found", + split.length + ? `Группа ${group} теперь разделена: ${split.join(", ")}. Выберите свою в списке групп.` + : `В документе нет группы ${group}.`, + ); } let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS); const lessons = schedule.lessons.filter((l) => l.groups.includes(group)); @@ -90,7 +105,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range, for (const entry of entries) { const status = occurrence(entry, date, calendar, views); if (status === "yes" || status === "maybe") - dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar)); + dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar, week)); } // Session events printed with their own date need no week table. for (const entry of extra) diff --git a/test/formats.test.js b/test/formats.test.js new file mode 100644 index 0000000..6441fa4 --- /dev/null +++ b/test/formats.test.js @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { groupsIn, alternatives, entries, details, leadingTime } from "../server/parser.js"; +import { roomMoves, weekPlan, onlyPlace } from "../server/rooms.js"; +import { peopleAndRooms, roomsOn, teachersOn, movedOn } from "../server/directory.js"; + +// Cell formats found by the catalog's diagnostics (October 2026). + +test("Группы с направлением в скобках: 24-ИЯ (РА)и-1 … и-6 — разные группы", () => { + assert.deepEqual(groupsIn("24-ИЯ (РА)и-1 | 24-ИЯ (РА)и-2 | 24-ИЯ (РА)и-3"), ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2", "24-ИЯ (РА)и-3"]); + assert.deepEqual(groupsIn("25 ИЯ (РА)и-1"), ["25-ИЯ (РА)и-1"]); + assert.deepEqual(groupsIn("24-РГФи-1 26-ИТ-2"), ["24-РГФи-1", "26-ИТ-2"]); +}); + +test("Перенос аудитории на дату, и с годом: «412н 26.10.2026-333»", () => { + assert.deepEqual(roomMoves("Типология (л.) Полякова Г.В. 412н 26.10.2026-333", 2026).moves, [{ date: "2026-10-26", room: "333" }]); + assert.deepEqual(roomMoves("Язык (п.з.) Горбач М.Н. 510н\n28.10.2026-504н", 2026).moves, [{ date: "2026-10-28", room: "504Н" }]); + assert.deepEqual(roomMoves("ауд. 432 14.10. -ауд. 333", 2026).moves, [{ date: "2026-10-14", room: "333" }]); + // The parser keeps the usual room; the move goes to that date only. + const lesson = { raw: "Типология (л.) Полякова Г.В. 412н 26.10.2026-333", room: "412н", teacher: "Полякова Г.В." }; + const entry = peopleAndRooms(lesson, "2026"); + assert.deepEqual(roomsOn(entry, "2026-10-26"), ["333"]); + assert.equal(movedOn(entry, "2026-10-26"), true); + assert.deepEqual(roomsOn(entry, "2026-10-19"), ["412Н"]); + assert.equal(movedOn(entry, "2026-10-19"), false); +}); + +test("Строка с аудиторией по неделям продолжает занятие, а не становится новым", () => { + const [one, ...rest] = entries("Язык профессионального общения (англ) (п.з.) Шабунина О.А.\n1-9н. 502н\nс 10н -359"); + assert.equal(rest.length, 0); + assert.equal(details(one.main).subject, "Язык профессионального общения (англ)"); + assert.deepEqual(one.plan, [ + { weeks: [[1, 9]], color: null, rooms: ["502Н"], teachers: [] }, + { weeks: [[10, null]], color: null, rooms: ["359"], teachers: [] }, + ]); + const entry = peopleAndRooms({ raw: one.text, room: "", teacher: "Шабунина О.А.", plan: one.plan }, "2026"); + assert.deepEqual(roomsOn(entry, "2026-10-20", { n: 8, color: "green" }), ["502Н"]); + assert.deepEqual(roomsOn(entry, "2026-11-03", { n: 10, color: "green" }), ["359"]); +}); + +test("Преподаватель по неделям и аудитория по цвету недели", () => { + const teachers = weekPlan("2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д"); + // The room at the end is the room of both parts. + assert.deepEqual(teachers, [ + { weeks: [[2, 9]], color: null, rooms: ["319Д"], teachers: ["Клос О.Ю."] }, + { weeks: [[10, 14]], color: null, rooms: ["319Д"], teachers: ["Конторов В.Э."] }, + ]); + const entry = peopleAndRooms({ raw: "2-14н. История литературы (п.з.)\n2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э. 319Д", room: "", teacher: "", plan: teachers }, "2026"); + assert.deepEqual(teachersOn(entry, { n: 3, color: "white" }), ["Клос О.Ю."]); + assert.deepEqual(teachersOn(entry, { n: 12, color: "green" }), ["Конторов В.Э."]); + + const [lesson] = entries("с 10н. Дискурсивная практика (рус) (п.з.) Булах А.С.\nбел.н.504н/зел.н.524н"); + const byColor = peopleAndRooms({ raw: lesson.text, room: "", teacher: "", plan: lesson.plan }, "2026"); + assert.deepEqual(roomsOn(byColor, "2026-11-10", { n: 11, color: "white" }), ["504Н"]); + assert.deepEqual(roomsOn(byColor, "2026-11-17", { n: 12, color: "green" }), ["524Н"]); + assert.equal(onlyPlace("1-9н. 502н"), true); + assert.equal(onlyPlace("13,15 н. РБ (л.з) Сивацкий Д.С.209н"), false); +}); + +test("Строка с видом и неделями без предмета — тот же предмет в другие недели", () => { + const list = entries("2-6н. Потребительское право (л.) Савицкая К.Д. ауд. А1-06\n7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) Савицкая К.Д. ауд. А1-06"); + assert.deepEqual(list.map((e) => [details(e.main).condition, details(e.main).subject, details(e.main).type]), [ + ["2-6н.", "Потребительское право", "Лекция"], + ["7,9,11н.", "Потребительское право", "Практика"], + ["8,12,14н.", "Потребительское право", "Практика"], + ]); + assert.equal(details(list[0].main).room, "А1-06"); +}); + +test("Дисциплины по выбору: заголовок, предмет перед неделями, виды по неделям", () => { + const read = (raw) => entries(raw).map((e) => ((d) => `${d.condition} ${d.subject} · ${d.type}`)(details(e.main))); + assert.deepEqual(read("Дисциплины по выбору: 1-16 н. Логика (л./п.з.) Магалинский И.В. ауд. С0-05\nСоциология 1-10 н. (л.) Шидловский С.О. 11-16 н. (п.з.) Лапацуева А.С.С0-03"), [ + "1-16 н. Логика · Лекция и практика", + "1-10 н. Социология · Лекция", + "11-16 н. Социология · Практика", + ]); + assert.deepEqual(read("Дисциплина по выбору: Социология ауд. С0-03\n1-10 н. (л.) Шидловский С.О.\n11-18 н. (п.з.) Лапацуева А.С."), [ + "1-10 н. Социология · Лекция", + "11-18 н. Социология · Практика", + ]); + assert.deepEqual(read("Дисциплина сцециализации: 4-14 н. Преступления в сфере информационных технологий (л.) Чижик Е.В. ауд. В0-05"), [ + "4-14 н. Преступления в сфере информационных технологий · Лекция", + ]); + assert.deepEqual(read("1-11н. - л., 12-14 – п.з. Защита населения Дмитриченко Н.С. ауд. 210м/к"), [ + "1-11н. Защита населения · Лекция", + "12-14н. Защита населения · Практика", + ]); +}); + +test("Время в начале ячейки: интервал с дефисом, заголовок со временем", () => { + assert.deepEqual(leadingTime("17:00-17:20-информационный час-Клос О.Ю. 319Д").starts, ["17:00"]); + assert.equal(leadingTime("17:00-17:20-информационный час").end, "17:20"); + assert.deepEqual(alternatives("18.35+20.10 Новополоцк:\n1-5 н. Судебно-следственная психология (л.) Колесникова Е.В. ауд. 246"), [ + "18.35+20.10 1-5 н. Судебно-следственная психология (л.) Колесникова Е.В. ауд. 246", + ]); +}); + +test("Подгруппа в тексте ячейки, точка вместо занятия, заголовок заглавными", () => { + const own = details("12н.2п/гр. Экономика организаций (л.з.) Лисиченок Е.П. ауд.331"); + assert.equal(own.subject, "Экономика организаций"); + assert.equal(own.ownSubgroup, 2); + assert.deepEqual(entries("."), []); + assert.equal(details("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ\nг. Полоцк, Полоцкий коллегиум\n26-ГЕО, 26-ИТ-1 Ректорский вход\n26-ИТ-2, 26-СТ обзорная экскурсия").subject, "ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ"); + // A two-line subject stays whole. + assert.equal(details("Интеллектуальная собственность - драйвер инновационного развития\nна пространстве Евразии").subject.includes("Евразии"), true); +}); + +test("Старое имя разделённой группы: понятная ошибка, другое написание — та же группа", async () => { + const { timetable } = await import("../server/timetable.js"); + const schedule = { groups: ["24-ИЯ (РА)и-1", "24-ИЯ (РА)и-2"], lessons: [], notes: [], period: "Осенний семестр", source: {} }; + const file = { id: "doc", name: "24-ИЯ (РА)и-1_24-ИЯ (РА)и-2.docx" }; + assert.throws( + () => timetable({ file, schedule, calendar: null, group: "24-ИЯ(РА)", from: "2026-10-19" }), + (e) => e.code === "group_not_found" && /разделена: 24-ИЯ \(РА\)и-1, 24-ИЯ \(РА\)и-2/.test(e.message), + ); + assert.equal(timetable({ file, schedule, calendar: null, group: "24-ИЯ(РА)и-1", from: "2026-10-19" }).group, "24-ИЯ (РА)и-1"); +}); diff --git a/test/parser.test.js b/test/parser.test.js index 2df5dee..63c023c 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -120,7 +120,7 @@ test("Даты практики в заголовке группы не стан ["23-ТТиИ"], ); assert.deepEqual(groupsIn("25-ИЯ(РА) 23 ИНС-1 24ДОи"), [ - "25-ИЯ(РА)", + "25-ИЯ (РА)", "23-ИНС-1", "24-ДОи", ]); diff --git a/test/snapshots/isf-daily.txt b/test/snapshots/isf-daily.txt index fdff924..0f24593 100644 --- a/test/snapshots/isf-daily.txt +++ b/test/snapshots/isf-daily.txt @@ -6,7 +6,6 @@ вт 13:40–15:05 | каждую неделю, нижняя строка | Информатика | Лекция | 362 | Василевский А.В. | 26-ПГС-1,26-ПГС-2 вт 15:20–16:45 | каждую неделю | Химия | Лабораторная | | Аврамёнок А.Н. | 26-ПГС-1,26-ПГС-1/п2 вт 15:20–16:45 | каждую неделю, верхняя строка | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-1,26-ПГС-1/п1 -вт 15:20–16:45 | нед. 13-17 | Иванов В.П. ауд.403 | Занятие | 403 | Иванов В.П. | 26-ПГС-1,26-ПГС-1/п2 пн 10:05–11:30 | каждую неделю | История белорусской государственности | Лекция | 432 | Кордюкевич О.Г. | 26-ПГС-1,26-ПГС-2 пн 11:45–13:10 | каждую неделю, верхняя строка | История белорусской государственности | Практика | 425 | Кордюкевич О.Г. | 26-ПГС-1 пн 11:45–13:10 | каждую неделю, верхняя строка | Физика | Практика | 337н | Солдатенко П.Н. | 26-ПГС-2 @@ -17,7 +16,6 @@ пн 15:20–16:45 | каждую неделю | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-2,26-ПГС-2/п2 пн 15:20–16:45 | каждую неделю | Химия | Лабораторная | | Аврамёнок А.Н. | 26-ПГС-1,26-ПГС-1/п1 пн 15:20–16:45 | каждую неделю, нижняя строка | Инженерная геодезия | Лабораторная | 325н | Шевелев И.П. | 26-ПГС-1,26-ПГС-1/п3 -пн 15:20–16:45 | нед. 13-17 | Иванов В.П. ауд.403 | Занятие | 403 | Иванов В.П. | 26-ПГС-1,26-ПГС-1/п1 пт 10:05–11:30 | каждую неделю, верхняя строка | Иностранный язык | Практика | 541н | Каменная В.А. | 26-ПГС-1,26-ПГС-1/п1 пт 10:05–11:30 | каждую неделю, верхняя строка | Начертательная геометрия, инженерная и машинная графика | Практика | 543н | Малаховская В.В. | 26-ПГС-1,26-ПГС-1/п2 пт 10:05–11:30 | каждую неделю, верхняя строка | Физика | Лабораторная | 205н | Сивацкий Д.С. | 26-ПГС-2,26-ПГС-2/п1