From 419e65e9605aeb8e7c08dd2cc7b0526f36ad8ff4 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 14 Sep 2026 01:37:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=81=D0=B5=D1=80:=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B9=20=E2=80=94=20=D1=83=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8F=20=D0=B2=D1=81=D0=B5=D0=B9=20=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В ячейке с несколькими занятиями (курсы по выбору) красная заметка, занимающая строку(и) целиком, относится ко всем занятиям ячейки (пара отменяется целиком) и раздаётся каждому занятию. Заметка, написанная в одной строке с предметом/аудиторией, остаётся привязанной к своему занятию. Многострочные standalone-заметки сохраняют переводы строк. Кейс: ПИЭ-13МО, понедельник, 5-я пара. Diff по 33 группам: затронуто только поле note в multi-lesson ячейках; порядок «inline->ячейка». --- src/backend/schedule/lessons.py | 90 +++++++++++++++++++++++++++------ tests/test_lessons.py | 26 ++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/backend/schedule/lessons.py b/src/backend/schedule/lessons.py index 8ba44de..4fb6b7b 100644 --- a/src/backend/schedule/lessons.py +++ b/src/backend/schedule/lessons.py @@ -4,8 +4,10 @@ необязательное: маркер, время, локация, примечания пишутся как придумается. Поэтому разбор не ветвится по количеству строк, а проходит один конвейер: -1. красные фрагменты таблицы (⟪...⟫) выносятся в заметки и удаляются из текста — - они служат только примечаниями, даже если внутри «вложено» другое занятие; +1. красные фрагменты таблицы выносятся в примечания: заметка, занимающая + строку(и) целиком, относится ко всей ячейке (несколько курсов «по выбору» + в паре отменяются вместе) и раздаётся всем занятиям ячейки; заметка + в одной строке с предметом/аудиторией остаётся привязанной к занятию; 2. извлекается маркер «к.в.» (своя строка или префикс первой строки); 3. каждая строка классифицируется по ролям: - аудитория — есть «ауд.»/«каб.», URL, «онлайн», «зал...» (вместе с названием @@ -355,17 +357,56 @@ def _extract_marker(lines: list[str]) -> tuple[str, list[str]]: return "", lines +def _unwrap_note(frag: str) -> str: + if frag.startswith("(") and frag.endswith(")"): + frag = frag[1:-1].strip() + return frag + + +def _squish_note(text: str) -> str: + """Как _squish, но переводы строк сохраняем: они несут смысл в заметках.""" + text = re.sub(r"[ \t]+\n", "\n", text) + return re.sub(r"[ \t]{2,}", " ", text).strip() + + +_SPAN_RE = re.escape(RED_OPEN) + r"(.*?)" + re.escape(RED_CLOSE) + + def _red_fragments(text: str) -> tuple[list[str], str]: - """Красные фрагменты -> примечания; текст остаётся на месте (пробелом).""" - frag_re = re.escape(RED_OPEN) + r"(.*?)" + re.escape(RED_CLOSE) + """Красные фрагменты -> примечания; на их месте остаётся пробел.""" frags = [] - for m in re.finditer(frag_re, text, re.S): - frag = _squish(m.group(1)) - if frag.startswith("(") and frag.endswith(")"): - frag = frag[1:-1].strip() + for m in re.finditer(_SPAN_RE, text, re.S): + frag = _squish_note(m.group(1)) if frag: - frags.append(frag) - return frags, re.sub(frag_re, " ", text, flags=re.S) + frags.append(_unwrap_note(frag)) + return frags, re.sub(_SPAN_RE, " ", text, flags=re.S) + + +# Строка(и), целиком занятая красным фрагментом (начинается с ⟪ на своей +# позиции, и весь фрагмент до ⟫ на конце строки; внутрь не попадает другой ⟪). +_CELL_NOTE_RE = re.compile( + r"^[ \t]*" + re.escape(RED_OPEN) + "([^" + re.escape(RED_OPEN) + r"]*?)" + re.escape(RED_CLOSE) + r"[ \t]*$", + re.M, +) + + +def _extract_cell_notes(text: str) -> tuple[list[str], str]: + """Вычленяет красные заметки уровня всей ячейки: занимающие строку(и) целиком. + + Такие заметки (например, «14.09 пары не будет» отдельной строкой) + относятся ко всем занятиям ячейки — курсы «по выбору» внутри одной пары + отменяются вместе. Заметки, приклеенные к содержательной строке + («онлайн ⟪начало в 14:30⟫»), остаются привязанными к своему занятию. + """ + notes: list[str] = [] + + def repl(m: re.Match) -> str: + frag = _squish_note(m.group(1)) + if frag: + notes.append(_unwrap_note(frag)) + return "" + + return notes, _CELL_NOTE_RE.sub(repl, text) def _balanced(text: str) -> str: @@ -385,16 +426,15 @@ def _parse_block(block: list[str]) -> Optional[Lesson]: lines = [ln.strip() for ln in text.split("\n") if ln.strip()] if not lines: if red_notes: - # в ячейке только примечание (например, «пары не будет») + # в блоке только примечание (например, «пары не будет») return Lesson(note="\n".join(dict.fromkeys(red_notes))) return None marker, lines = _extract_marker(lines) if not lines: if red_notes: - # в ячейке только примечание (например, «пары не будет») return Lesson(marker=marker, note="\n".join(dict.fromkeys(red_notes))) - return None # в ячейке только маркер — занятия ещё нет + return None # в блоке только маркер — занятия ещё нет subjects: list[str] = [] teachers: list[str] = [] @@ -456,13 +496,21 @@ def _normalize_marker_of(line: str) -> Optional[str]: def parse_lesson_cell(cell_text: str) -> list[Lesson]: - """Текст ячейки -> список занятий.""" + """Текст ячейки -> список занятий. + + Задача: несколько занятий в одной ячейке (курсы «по выбору») отменяются + вместе, поэтому красная заметка, занимающая строку(и) целиком, — уровня + всей ячейки и раздаётся всем занятиям. Заметка, написанная в одной строке + с предметом/аудиторией, остаётся привязанной к своему занятию. + """ if not cell_text or not cell_text.strip(): return [] - lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + text = cell_text.replace("\r\n", "\n").replace("\r", "\n") + cell_notes, text = _extract_cell_notes(text) + lessons: list[Lesson] = [] last_marker = "" - for block in _split_blocks(lines): + for block in _split_blocks(text.split("\n")): if len(block) == 1 and not _line_is_room(block[0]): standalone = _normalize_marker_of(block[0]) if standalone: @@ -476,6 +524,16 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]: elif last_marker: lesson = replace(lesson, marker=last_marker) lessons.append(lesson) + + if not cell_notes: + return lessons + if not lessons: + return [Lesson(note="\n".join(dict.fromkeys(cell_notes)))] + for idx, lesson in enumerate(lessons): + have = [n for n in lesson.note.split("\n") if n] + add = [c for c in cell_notes if c not in have] + if add: + lessons[idx] = replace(lesson, note="\n".join(have + add)) return lessons diff --git a/tests/test_lessons.py b/tests/test_lessons.py index ed9e2ea..a7b7cf1 100644 --- a/tests/test_lessons.py +++ b/tests/test_lessons.py @@ -347,3 +347,29 @@ def test_full_name_inline_after_subject(): assert result["subject"] == "Основы тестирования веб-приложений" assert result["teacher"] == "Денис Верховцев (Сигма)" assert result["room"] == "онлайн" + + +def test_cell_note_applies_to_all_lessons(): + # ПИЭ-13МО, понедельник, 5-я пара: два курса по выбору, заметка о паре + raw = ( + "к.в. Управление ИТ-проектами\nЕрофеев Александр Александрович (ПравоТех)\nонлайн\n" + "к.в. Математические методы и модели принятия решения Наука онлайн\n" + + R + "14.09 пары не будет" + Cc + ) + ls = lessons(raw) + assert len(ls) == 2 + assert all(l["note"] == "14.09 пары не будет" for l in ls) + assert {l["subject"] for l in ls} == { + "Управление ИТ-проектами", + "Математические методы и модели принятия решения", + } + + +def test_inline_note_stays_with_its_lesson(): + raw = ( + "Русский язык\nШаманова М.В.\nауд. 312 " + R + "с 7.09 по 20.09 пары не будет" + Cc + + "\n\nМатематика\nСмирнов А.В.\nауд. 203" + ) + ls = lessons(raw) + assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет" + assert ls[1]["note"] == ""