Базовая функциональность

This commit is contained in:
2026-09-08 11:14:33 +03:00
commit dcd9b854a0
18 changed files with 2855 additions and 0 deletions

298
src/backend/main.py Normal file
View File

@@ -0,0 +1,298 @@
import os
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from schedule_parser import (
DAYS,
ScheduleError,
build_day_times,
build_group_schedule,
build_room_schedule,
build_teacher_room_index,
build_teacher_schedule,
current_parity,
download_schedule,
extract_week_ranges,
fetch_table_modified,
parse_rooms_info,
parse_schedule,
parse_teachers_full,
parse_workbook,
sort_rooms,
)
def _find_dotenv():
path = Path(__file__).resolve().parent
for _ in range(5):
env = path / ".env"
if env.is_file():
return env
if path.parent == path:
break
path = path.parent
return None
_dotenv_path = _find_dotenv()
if _dotenv_path:
load_dotenv(_dotenv_path)
SCHEDULE_LINK = os.getenv("schedule_link") or os.getenv("SCHEDULE_LINK", "").strip()
CACHE_TTL = int(os.getenv("SCHEDULE_CACHE_TTL", "900"))
_cache = {
"data": None,
"weeks": None,
"modified": None,
"loaded_at": 0.0,
"error": None,
"index": None,
"teacher_full": {},
"room_info": {},
}
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
_load_data()
except Exception:
pass
yield
app = FastAPI(
title="Расписание",
description="Расписание групп из Яндекс.Таблицы",
lifespan=lifespan,
)
def _load_data(force: bool = False):
if not SCHEDULE_LINK:
raise ScheduleError("Ссылка schedule_link не задана в .env")
now = time.time()
if (
not force
and _cache["data"] is not None
and now - _cache["loaded_at"] < CACHE_TTL
and _cache["error"] is None
):
return _cache["data"], _cache["weeks"]
modified = fetch_table_modified(SCHEDULE_LINK)
if (
not force
and modified
and modified == _cache["modified"]
and _cache["data"] is not None
and _cache["error"] is None
):
_cache["loaded_at"] = now
return _cache["data"], _cache["weeks"]
xlsx = download_schedule(SCHEDULE_LINK)
sheets = parse_workbook(xlsx)
data = parse_schedule(sheets)
weeks = extract_week_ranges(sheets)
_cache["data"] = data
_cache["weeks"] = weeks
_cache["modified"] = modified
_cache["loaded_at"] = now
_cache["error"] = None
_cache["index"] = None
_cache["teacher_full"] = parse_teachers_full(sheets)
_cache["room_info"] = parse_rooms_info(sheets)
return data, weeks
def _get_index(force: bool = False):
data, _ = _get_data(force=force)
if _cache["index"] is None:
_cache["index"] = build_teacher_room_index(data)
return _cache["index"]
def _get_data(force: bool = False):
try:
return _load_data(force=force)
except ScheduleError as exc:
_cache["error"] = str(exc)
raise HTTPException(status_code=502, detail=str(exc))
@app.get("/")
def index():
return {
"title": "Расписание",
"endpoints": {
"/groups": "Список групп",
"/schedule/{group}": "Расписание группы",
"/schedule/{group}/{day}": "Расписание группы на день",
"/today?group={group}": "Расписание группы на сегодня",
},
}
@app.get("/groups")
def groups(force: bool = Query(False)):
data, _ = _get_data(force=force)
return sorted(
data.keys(),
key=lambda g: (
0 if g.endswith("БО") else (1 if g.endswith("МО") else 2),
g,
),
)
@app.get("/teachers")
def teachers(force: bool = Query(False)):
teachers_map, _ = _get_index(force=force)
return sorted(teachers_map.keys())
@app.get("/rooms")
def rooms(force: bool = Query(False)):
_, rooms_map = _get_index(force=force)
return sort_rooms(rooms_map.keys())
@app.get("/teacher/{name}")
def teacher(name: str, force: bool = Query(False)):
teachers_map, _ = _get_index(force=force)
result = build_teacher_schedule(teachers_map, name)
if result is None:
raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден")
result["full_name"] = _cache.get("teacher_full", {}).get(name, name)
return result
@app.get("/room/{label}")
def room(label: str, force: bool = Query(False)):
data, _ = _get_data(force=force)
_, rooms_map = _get_index(force=force)
result = build_room_schedule(rooms_map, label, build_day_times(data))
if result is None:
raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена")
result["info"] = _cache.get("room_info", {}).get(label, {}).get("info", "")
return result
@app.get("/rooms-free")
def rooms_free(
day: str,
period: int,
force: bool = Query(False),
):
normalized = day.strip().lower()
if normalized not in DAYS:
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
if period < 1 or period > 8:
raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8")
_, rooms_map = _get_index(force=force)
room_info = _cache.get("room_info", {})
free = []
for label in sort_rooms(room_info.keys()):
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
if not busy:
free.append({"room": label, "info": room_info[label].get("info", "")})
return free
@app.get("/rooms-day")
def rooms_day(day: str, force: bool = Query(False)):
normalized = day.strip().lower()
if normalized not in DAYS:
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
data, _ = _get_data(force=force)
_, rooms_map = _get_index(force=force)
room_info = _cache.get("room_info", {})
day_times = build_day_times(data).get(normalized, {})
labels = set(rooms_map.keys()) | set(room_info.keys())
rooms_out = []
for label in sort_rooms(labels):
info = room_info.get(label, {}).get("info", "")
occupied_day = rooms_map.get(label, {}).get(normalized, {})
periods = {}
keys = set(occupied_day.keys()) | set(day_times.keys())
for period in sorted(keys, key=lambda p: int(p)):
slot = occupied_day.get(period, {})
periods[str(period)] = {
"time": day_times.get(period, slot.get("time", "")),
"ч": slot.get("ч", []) or [],
"з": slot.get("з", []) or [],
}
rooms_out.append({"room": label, "info": info, "periods": periods})
return {"day": normalized, "rooms": rooms_out}
@app.get("/week")
def week(force: bool = Query(False)):
_, weeks = _get_data(force=force)
today = datetime.now().date()
return {
"today": today.isoformat(),
"parity": current_parity(weeks, today),
"weeks": [
{"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()}
for w in weeks
],
}
@app.get("/status")
def status(force: bool = Query(False)):
_get_data(force=force)
return {
"cache_ttl_seconds": CACHE_TTL,
"last_updated": (
datetime.fromtimestamp(_cache["loaded_at"], datetime.now().astimezone().tzinfo).isoformat() if _cache["loaded_at"] else None
),
"table_modified": _cache["modified"],
}
@app.get("/schedule/{group}")
def schedule(group: str, force: bool = Query(False)):
data, _ = _get_data(force=force)
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
return build_group_schedule(data, group)
@app.get("/schedule/{group}/{day}")
def schedule_day(group: str, day: str, force: bool = Query(False)):
data, _ = _get_data(force=force)
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
normalized = day.strip().lower()
if normalized not in DAYS:
raise HTTPException(status_code=400, detail=f"День должен быть одним из: {', '.join(DAYS)}")
sched = build_group_schedule(data, group)
day_data = sched["days"].get(normalized, {})
return {"group": group, "day": normalized, "periods": day_data}
@app.get("/today")
def today(group: str, force: bool = Query(False)):
data, _ = _get_data(force=force)
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
weekday = datetime.now().weekday()
day_name = DAYS[weekday]
sched = build_group_schedule(data, group)
return {"group": group, "date": datetime.now().date().isoformat(), "day": day_name, "periods": sched["days"].get(day_name, {})}
if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

View File

@@ -0,0 +1,799 @@
import io
import re
import zipfile
import xml.etree.ElementTree as ET
from datetime import date, datetime, timedelta
import requests
XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
MAIN_SHEET = "Бакалавры и магистры"
DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
_PERIOD_RE = re.compile(r"^\s*(\d+)\s*(.*)$")
_REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
class ScheduleError(Exception):
pass
def _iter_text(node):
for el in node.iter(f"{{{XLSX_NS}}}t"):
if el.text:
yield el.text
def _col_index(col_letter):
idx = 0
for ch in col_letter:
idx = idx * 26 + (ord(ch) - ord("A") + 1)
return idx - 1
def _parse_ref(ref):
m = _REF_RE.match(ref)
if not m:
raise ValueError(f"Некорректная ссылка на ячейку: {ref}")
return _col_index(m.group(1)), int(m.group(2)) - 1
RED_OPEN = "\u27EA"
RED_CLOSE = "\u27EB"
def _is_red_rgb(rgb):
try:
if not rgb or len(rgb) < 8 or not rgb.startswith("FF"):
return False
r = int(rgb[2:4], 16)
g = int(rgb[4:6], 16)
b = int(rgb[6:8], 16)
except (ValueError, TypeError):
return False
return r > 0x90 and g < 0x70 and b < 0x70
def _parse_shared_strings(xml_bytes):
root = ET.fromstring(xml_bytes)
out = []
for si in root.iter(f"{{{XLSX_NS}}}si"):
parts = []
for child in si:
if child.tag == f"{{{XLSX_NS}}}t":
parts.append((child.text or "", False))
continue
if child.tag != f"{{{XLSX_NS}}}r":
continue
t = child.find(f"{{{XLSX_NS}}}t")
text = t.text if t is not None else ""
red = False
rpr = child.find(f"{{{XLSX_NS}}}rPr")
if rpr is not None:
color = rpr.find(f"{{{XLSX_NS}}}color")
if color is not None:
red = _is_red_rgb(color.get("rgb"))
parts.append((text or "", red))
chunks = []
for text, red in parts:
if red:
chunks.append(RED_OPEN + text + RED_CLOSE)
else:
chunks.append(text)
out.append("".join(chunks))
return out
def _parse_sheet(xml_bytes, shared):
root = ET.fromstring(xml_bytes)
cells = {}
max_row = 0
max_col = 0
hidden_cols = set()
cols_node = root.find(f".//{{{XLSX_NS}}}cols")
if cols_node is not None:
for col in cols_node.iter(f"{{{XLSX_NS}}}col"):
if col.get("hidden") == "1":
try:
c_min = int(col.get("min"))
c_max = int(col.get("max"))
except (TypeError, ValueError):
continue
hidden_cols.update(range(c_min - 1, c_max))
for row in root.iter(f"{{{XLSX_NS}}}row"):
r = int(row.get("r")) - 1
for cell in row.iter(f"{{{XLSX_NS}}}c"):
ref = cell.get("r")
if not ref:
continue
c = _col_index("".join(ch for ch in ref if ch.isalpha()))
t = cell.get("t")
v = cell.find(f"{{{XLSX_NS}}}v")
if t == "s" and v is not None and v.text:
idx = int(v.text)
value = shared[idx] if idx < len(shared) else ""
elif v is not None and v.text is not None:
value = v.text
else:
is_node = cell.find(f"{{{XLSX_NS}}}is")
value = "".join(_iter_text(is_node)) if is_node is not None else ""
if value != "":
cells[(r, c)] = value
max_row = max(max_row, r)
max_col = max(max_col, c)
merge_node = root.find(f".//{{{XLSX_NS}}}mergeCells")
if merge_node is not None:
for mc in merge_node.iter(f"{{{XLSX_NS}}}mergeCell"):
ref = mc.get("ref")
if not ref or ":" not in ref:
continue
a, b = ref.split(":")
c1, r1 = _parse_ref(a)
c2, r2 = _parse_ref(b)
max_row = max(max_row, r2)
max_col = max(max_col, c2)
top = cells.get((r1, c1), "")
if not top:
continue
for rr in range(r1, r2 + 1):
for cc in range(c1, c2 + 1):
if (rr, cc) not in cells:
cells[(rr, cc)] = top
matrix = [
[cells.get((r, c), "") for c in range(max_col + 1)]
for r in range(max_row + 1)
]
return matrix, hidden_cols
def _read_cell(row, col_idx):
if col_idx < len(row):
return row[col_idx]
return ""
def download_schedule(public_url):
try:
resp = requests.get(
"https://cloud-api.yandex.net/v1/disk/public/resources/download",
params={"public_key": public_url},
timeout=30,
)
resp.raise_for_status()
href = resp.json()["href"]
file_resp = requests.get(href, timeout=60)
file_resp.raise_for_status()
return file_resp.content
except Exception as exc:
raise ScheduleError(f"Не удалось скачать таблицу: {exc}") from exc
def fetch_table_modified(public_url):
try:
resp = requests.get(
"https://cloud-api.yandex.net/v1/disk/public/resources",
params={"public_key": public_url},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("modified")
except Exception:
return None
def parse_workbook(xlsx_bytes):
try:
zf = zipfile.ZipFile(io.BytesIO(xlsx_bytes))
except zipfile.BadZipFile as exc:
raise ScheduleError("Скачанный файл не является xlsx") from exc
shared = []
try:
shared = _parse_shared_strings(zf.read("xl/sharedStrings.xml"))
except KeyError:
pass
rels_root = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
rel_map = {}
for rel in rels_root:
rid = rel.get("Id")
target = rel.get("Target")
if rid and target:
rel_map[rid] = target
wb_root = ET.fromstring(zf.read("xl/workbook.xml"))
sheets = {}
for sheet in wb_root.iter(f"{{{XLSX_NS}}}sheet"):
name = sheet.get("name")
rid = sheet.get(f"{{{REL_NS}}}id")
if name and rid and rid in rel_map:
target = rel_map[rid]
if target.startswith("worksheets/"):
matrix, hidden_cols = _parse_sheet(zf.read("xl/" + target), shared)
sheets[name] = {"rows": matrix, "hidden_cols": hidden_cols}
return sheets
def _normalize_header(text):
return text.split("\n", 1)[0].strip()
def _excel_serial_to_date(serial):
try:
value = float(str(serial).replace(",", "."))
except (TypeError, ValueError):
return None
if value <= 0:
return None
if value <= 60:
value -= 1
return (datetime(1899, 12, 30) + timedelta(days=value)).date()
def extract_week_ranges(sheets):
if MAIN_SHEET not in sheets:
return []
matrix = sheets[MAIN_SHEET]["rows"]
ranges = []
seen = set()
for row in matrix[1:]:
if not any(_read_cell(row, i) for i in range(4)):
continue
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
start = _excel_serial_to_date(_read_cell(row, start_col))
end = _excel_serial_to_date(_read_cell(row, end_col))
if start and end:
key = (parity, start)
if key not in seen:
seen.add(key)
ranges.append({"parity": parity, "start": start, "end": end})
return sorted(ranges, key=lambda w: w["start"])
def current_parity(week_ranges, today=None):
today = today or date.today()
for week in week_ranges:
if week["start"] <= today <= week["end"]:
return week["parity"]
return None
def parse_schedule(sheets):
if MAIN_SHEET not in sheets:
raise ScheduleError(f"Лист «{MAIN_SHEET}» не найден в таблице")
sheet = sheets[MAIN_SHEET]
matrix = sheet["rows"]
hidden_cols = sheet["hidden_cols"]
header = matrix[0]
group_columns = {}
seen_names = set()
group_re = re.compile(r"^[А-ЯЁ]{2,4}-\d")
for idx, cell in enumerate(header):
if idx in hidden_cols:
continue
name = _normalize_header(cell)
if name and group_re.match(name) and name not in seen_names:
group_columns[idx] = name
seen_names.add(name)
groups = {}
for col, name in group_columns.items():
header_text = _read_cell(header, col)
semester = None
if "\n" in header_text:
second = header_text.split("\n", 1)[1].strip()
m = re.search(r"(\d{1,2}\.\d{2}\.\d{2})", second)
if m:
semester = m.group(1)
groups[name] = {"semester_until": semester, "days": {}}
day_periods = {}
cur_day = None
period = None
cur_time = ""
parity = None
for row in matrix[1:]:
has_dates = any(_read_cell(row, i) for i in range(4))
if not has_dates:
continue
day = _read_cell(row, 4)
if day:
normalized = day.strip().lower()
if normalized in DAYS:
if normalized != cur_day:
cur_day = normalized
parity = None
if cur_day is None:
continue
period_cell = _read_cell(row, 5)
if period_cell:
lines = [ln.strip() for ln in period_cell.split("\n") if ln.strip()]
m = _PERIOD_RE.match(lines[0])
if m:
period = int(m.group(1))
time_parts = [m.group(2).strip()]
time_parts += lines[1:]
cur_time = " ".join(p for p in time_parts if p)
cur_time = re.sub(r"\s*[-–—]\s*", "-", cur_time)
day_periods.setdefault(cur_day, {}).setdefault(period, cur_time)
if period is None:
continue
parity = "з" if parity == "ч" else "ч"
for col, group_name in group_columns.items():
cell_text = _read_cell(row, col)
if cell_text:
days_map = groups[group_name]["days"].setdefault(cur_day, {})
slot = days_map.setdefault(period, {"time": cur_time, "ч": None, "з": None})
slot[parity] = cell_text
for group_name in groups:
for day, periods in day_periods.items():
days_map = groups[group_name]["days"]
for period, time in periods.items():
days_map.setdefault(day, {})[period] = days_map.get(day, {}).get(
period, {"time": time, "ч": None, "з": None}
)
days_map[day] = dict(sorted(days_map[day].items()))
return groups
_MARKER_RE = re.compile(r"к\.в\.?\s*-?\s*\d*\s*", re.IGNORECASE)
_ROOM_LINE_RE = re.compile(r"ауд\.\s*[\w\-]+", re.IGNORECASE)
_SUBJECT_START_RE = re.compile(r"^[А-ЯЁA-Z]")
def _split_lesson_blocks(cell_text):
lines = cell_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
blocks = []
current = []
prev_blank = True
prev_room_line = False
for raw in lines:
line = raw.strip()
if not line:
prev_blank = True
continue
starts_marker = bool(_MARKER_RE.match(line))
room_line = bool(_ROOM_LINE_RE.search(line))
subject_start = bool(_SUBJECT_START_RE.match(line)) and not any(
w in line for w in ("не будет", "отменяется", "переносится")
)
if current and (prev_blank or starts_marker or (prev_room_line and subject_start)):
blocks.append(current)
current = []
current.append(line)
prev_blank = False
prev_room_line = room_line
if current:
blocks.append(current)
return blocks
def _lesson(marker, subject, teacher, room):
room = re.sub(r"ауд\.?\s*", "ауд. ", room, flags=re.IGNORECASE)
room = re.sub(r"\s{2,}", " ", room).strip()
return {
"marker": marker,
"subject": subject,
"teacher": teacher,
"room": room,
}
def _split_subject_teacher(head):
head = head.strip()
name_re = re.compile(
r"([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+)(\s*\([^)]*\))*$"
)
name_m = name_re.search(head)
if name_m:
teacher = name_m.group(1).strip()
subject = (head[: name_m.start()] + (name_m.group(2) or "")).strip()
return subject, teacher
words = head.split()
if len(words) >= 2 and words[-1][:1].isupper():
return " ".join(words[:-1]), words[-1]
return head, ""
def _parse_inline(text, marker):
text = text.strip()
m = None
for match in re.finditer(r"ауд\.?\s*[\d\-]+\w*", text, re.IGNORECASE):
m = match
if not m:
return _lesson(marker, text, "", "")
room = m.group(0).strip()
head = text[: m.start()].strip()
subject, teacher = _split_subject_teacher(head)
return _lesson(marker, subject, teacher, room)
def _parse_lesson(lines):
lines = [
ln.replace(RED_OPEN, "").replace(RED_CLOSE, "").strip()
for ln in lines
if ln.strip()
]
if not lines:
return None
marker = None
m = re.match(r"^(к\.в\.?\s*-?\s*\d*)\s*(.*)$", lines[0], re.IGNORECASE)
if m:
marker = m.group(1).strip()
rest_head = m.group(2).strip()
if rest_head:
lines[0] = rest_head
else:
lines = lines[1:]
if not lines:
return None
if len(lines) == 1:
return _parse_inline(lines[0], marker)
if len(lines) == 2:
second = lines[1]
if re.search(r"(ауд\.|http|зал|каб\.|онлайн)", second, re.IGNORECASE):
subject, teacher = _split_subject_teacher(lines[0])
return _lesson(marker, subject, teacher, second)
if re.search(r"\b[А-ЯЁ]\.[А-ЯЁ]\.?$", second):
return _lesson(marker, lines[0], second, "")
return _lesson(marker, lines[0], "", second)
# >=3 строк: ищем строку аудитории, преподаватель — строка перед ней;
# строки после аудитории (примечания) не ломают разбор
room_idx = None
for i in range(len(lines) - 1, -1, -1):
if _ROOM_LINE_RE.match(lines[i]):
room_idx = i
break
if room_idx is None:
room = lines[-1]
teacher = lines[-2]
subject = " ".join(lines[:-2])
return _lesson(marker, subject, teacher, room)
room = lines[room_idx]
before = lines[:room_idx]
teacher = ""
teacher_idx = None
for i in range(len(before) - 1, -1, -1):
cand = before[i]
if _looks_like_teacher_line(cand):
teacher_idx = i
teacher = cand
break
if teacher_idx is not None:
subject = " ".join(before[:teacher_idx])
else:
subject = " ".join(before)
return _lesson(marker, subject, teacher, room)
def _unwrap_parens(text):
text = text.strip()
if text.startswith("(") and text.endswith(")"):
inner = text[1:-1].strip()
if inner:
return inner
return text
def _collect_notes(text):
notes = []
for m in re.findall(r"⟪([^⟫]*)⟫", text):
frag = m.strip()
if frag:
notes.append((_unwrap_parens(frag), True))
plain = re.sub(r"⟪[^⟫]*⟫", "", text)
for m in re.finditer(r"\(([^()]*)\)", plain):
inner = m.group(1).strip()
if not inner:
continue
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", inner):
continue
if not re.search(r"[а-яёА-ЯЁa-zA-Z]", inner):
continue
notes.append((_unwrap_parens(inner), False))
return notes
def _strip_note_markers(value):
return value.replace(RED_OPEN, "").replace(RED_CLOSE, "")
def _split_lessons(cell_text):
lessons = []
last_marker = None
for block in _split_lesson_blocks(cell_text):
block_text = "\n".join(block)
lesson = _parse_lesson(block)
if lesson is None:
continue
for key in ("marker", "subject", "teacher", "room"):
if lesson[key]:
lesson[key] = _strip_note_markers(lesson[key])
fields = " ".join(
x for x in (lesson["subject"], lesson["teacher"], lesson["room"]) if x
)
keep = []
seen_notes = set()
for note_text, is_red in _collect_notes(block_text):
if not note_text or note_text in seen_notes:
continue
if not is_red and note_text in fields:
continue
seen_notes.add(note_text)
keep.append(note_text)
lesson["note"] = "\n".join(keep)
if lesson["marker"]:
last_marker = lesson["marker"]
elif last_marker:
lesson["marker"] = last_marker
lessons.append(lesson)
return lessons
def build_group_schedule(data, group_name):
group = data.get(group_name)
if group is None:
return None
out = {
"group": group_name,
"semester_until": group.get("semester_until"),
"days": {},
}
for day, periods in group["days"].items():
out["days"][day] = {}
for period, slot in periods.items():
entry = {"time": slot.get("time", "")}
for parity in ("ч", "з"):
raw = slot.get(parity)
if raw:
entry[parity] = {"raw": raw, "lessons": _split_lessons(raw)}
else:
entry[parity] = None
out["days"][day][str(period)] = entry
return out
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.IGNORECASE)
def _iter_lessons(schedule_data):
for group, gdata in schedule_data.items():
for day, periods in gdata.get("days", {}).items():
for period, slot in periods.items():
time = slot.get("time", "")
for parity in ("ч", "з"):
raw = slot.get(parity)
if not raw:
continue
for lesson in _split_lessons(raw):
yield group, day, period, time, parity, lesson
def _is_name(text):
if not text:
return False
if text.startswith(("(", "—", "–", "-", "«", '"')):
return False
if any(w in text for w in ("не будет", "отменяется", "переносится", "пара будет")):
return False
if not re.search(r"[А-ЯЁа-яё]", text):
return False
if re.search(r"\d", text) and not re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
return False
return True
_NAME_RE = re.compile(r"[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ]\.[А-ЯЁ]\.?)+")
def _extract_teachers(text):
return [m.strip() for m in _NAME_RE.findall(text) if m.strip()]
def _is_org(text):
if not _is_name(text):
return False
if any(ch in text for ch in "()"):
return False
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]", text):
return False
return True
_ORG_LINE_RE = re.compile(
r"[А-ЯЁ][A-Za-zА-ЯЁа-яё-]*(?:\s+[А-ЯЁ][A-Za-zА-ЯЁа-яё-]+)?"
)
def _looks_like_teacher_line(text):
if re.search(r"[А-ЯЁ]\.[А-ЯЁ]\.?", text):
return True
return bool(_ORG_LINE_RE.fullmatch(text)) and len(text) <= 40
def _merge_entry(slot_list, entry_key, fields):
for item in slot_list:
if all(item.get(k) == v for k, v in entry_key.items()):
return item
item = dict(entry_key)
item["groups"] = []
item.update(fields)
slot_list.append(item)
return item
def build_teacher_room_index(schedule_data):
teachers = {}
rooms = {}
for group, day, period, time, parity, lesson in _iter_lessons(schedule_data):
subject = lesson["subject"]
room = lesson["room"].strip()
if lesson["teacher"]:
teachers_found = _extract_teachers(lesson["teacher"])
if not teachers_found and _is_org(lesson["teacher"]):
teachers_found = [lesson["teacher"].strip()]
for teacher in teachers_found:
days_map = teachers.setdefault(teacher, {}).setdefault(day, {})
slot = days_map.setdefault(
str(period), {"time": time, "ч": [], "з": []}
)
item = _merge_entry(
slot[parity],
{"subject": subject, "room": room},
{"marker": lesson["marker"]},
)
if group not in item["groups"]:
item["groups"].append(group)
display_teacher = ", ".join(teachers_found) if teachers_found else ""
else:
display_teacher = ""
room_m = _ROOM_RE.match(room)
if room_m:
label = "ауд. " + room_m.group(1)
days_map = rooms.setdefault(label, {}).setdefault(day, {})
slot = days_map.setdefault(
str(period), {"time": time, "ч": [], "з": []}
)
item = _merge_entry(
slot[parity],
{"subject": subject, "teacher": display_teacher},
{"marker": lesson["marker"]},
)
if group not in item["groups"]:
item["groups"].append(group)
return teachers, rooms
def sort_rooms(room_names):
def key(name):
m = re.search(r"(\d+)", name)
return (0, int(m.group(1))) if m else (1, name)
return sorted(room_names, key=key)
def parse_rooms_info(sheets):
"""Аудитории: номер (заголовок) -> оснащение/вместимость (2-я строка)."""
name = "аудитории"
if name not in sheets:
return {}
rows = sheets[name]["rows"]
if len(rows) < 2:
return {}
header = rows[0]
desc = rows[1]
info = {}
for col in range(6, max(len(header), len(desc))):
raw = _read_cell(header, col)
m = re.search(r"(\d+)", str(raw))
if not m:
continue
number = int(m.group(1))
text = " ".join(
line.strip()
for line in _read_cell(desc, col).split("\n")
if line.strip()
)
label = f"ауд. {number}"
info[label] = {"room": label, "info": text}
return info
def _initials_from_parts(first, patronymic):
initials = ""
if first:
initials += first[0].upper() + "."
if patronymic:
initials += patronymic[0].upper() + "."
return initials
def parse_teachers_full(sheets):
"""Преподаватели: короткое «Фамилия И.О.» -> полное ФИО (3-я строка-заголовок)."""
name = "Преподаватели"
if name not in sheets:
return {}
rows = sheets[name]["rows"]
if len(rows) < 2:
return {}
header = rows[1]
result = {}
for col in range(6, len(header)):
value = _read_cell(header, col)
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
if not parts:
continue
surname = parts[0]
first = parts[1] if len(parts) > 1 else ""
patronymic = parts[2] if len(parts) > 2 else ""
short = f"{surname} {_initials_from_parts(first, patronymic)}".strip()
full = " ".join(parts)
if short and full:
result.setdefault(short, full)
return result
def build_teacher_schedule(teachers, name):
if name not in teachers:
return None
return {"name": name, "days": teachers[name]}
def build_day_times(schedule_data):
day_times = {}
for gdata in schedule_data.values():
for day, periods in gdata.get("days", {}).items():
target = day_times.setdefault(day, {})
for period, slot in periods.items():
target.setdefault(str(period), slot.get("time", ""))
return day_times
def build_room_schedule(rooms, label, day_times=None):
if label not in rooms:
return None
data = rooms[label]
out = {"room": label, "days": {}}
day_times = day_times or {}
all_days = {*data.keys(), *day_times.keys()}
for day in sorted(all_days, key=lambda d: DAYS.index(d) if d in DAYS else 99):
out["days"][day] = {}
times = day_times.get(day, {})
periods = {*data.get(day, {}).keys(), *times.keys()}
for period in sorted(periods, key=lambda p: int(p)):
entry = {"time": times.get(period, data.get(day, {}).get(period, {}).get("time", ""))}
occupied = data.get(day, {}).get(period, {})
for parity in ("ч", "з"):
lessons = occupied.get(parity) or []
entry[parity] = lessons if lessons else None
out["days"][day][str(period)] = entry
return out

93
src/frontend/main.py Normal file
View File

@@ -0,0 +1,93 @@
import os
from pathlib import Path
import requests
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
load_dotenv(Path(__file__).parent / ".env")
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
STATIC_DIR = Path(__file__).parent / "static"
app = FastAPI(title="Расписание — фронт")
def _proxy(path: str, params: dict):
try:
resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=30)
except requests.RequestException as exc:
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
if resp.status_code != 200:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
raise HTTPException(status_code=resp.status_code, detail=detail)
return resp.json()
@app.get("/api/groups")
def api_groups(force: bool = Query(False)):
return _proxy("groups", {"force": force})
@app.get("/api/schedule/{group}")
def api_schedule(group: str, force: bool = Query(False)):
return _proxy(f"schedule/{group}", {"force": force})
@app.get("/api/teachers")
def api_teachers(force: bool = Query(False)):
return _proxy("teachers", {"force": force})
@app.get("/api/rooms")
def api_rooms(force: bool = Query(False)):
return _proxy("rooms", {"force": force})
@app.get("/api/teacher/{name}")
def api_teacher(name: str, force: bool = Query(False)):
return _proxy(f"teacher/{name}", {"force": force})
@app.get("/api/room/{label}")
def api_room(label: str, force: bool = Query(False)):
return _proxy(f"room/{label}", {"force": force})
@app.get("/api/rooms-free")
def api_rooms_free(day: str, period: int, force: bool = Query(False)):
return _proxy("rooms-free", {"day": day, "period": period, "force": force})
@app.get("/api/rooms-day")
def api_rooms_day(day: str, force: bool = Query(False)):
return _proxy("rooms-day", {"day": day, "force": force})
@app.get("/api/week")
def api_week(force: bool = Query(False)):
return _proxy("week", {"force": force})
@app.get("/api/status")
def api_status(force: bool = Query(False)):
return _proxy("status", {"force": force})
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8080)

526
src/frontend/static/app.js Normal file
View File

@@ -0,0 +1,526 @@
const groupSelect = document.getElementById("group-select");
const entityLabel = document.getElementById("entity-label");
const themeToggle = document.getElementById("theme-toggle");
const scheduleEl = document.getElementById("schedule");
const errorEl = document.getElementById("error");
const loadingEl = document.getElementById("loading");
const RUSSIAN_DAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
const DAY_ORDER = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
const MODES = {
groups: { label: "Группа", listUrl: "/api/groups", valueKey: "schedule-group" },
teachers: { label: "Преподаватель", listUrl: "/api/teachers", valueKey: "schedule-teacher" },
rooms: { label: "Аудитория", listUrl: "/api/rooms", valueKey: "schedule-room" },
};
const PARITY_LABEL = { ч: "Числитель", з: "Знаменатель" };
const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
let currentParity = null;
let mode = "groups";
function todayName() {
return RUSSIAN_DAYS[new Date().getDay()];
}
function enc(value) {
return encodeURIComponent(value);
}
async function fetchJSON(url) {
const resp = await fetch(url);
if (!resp.ok) {
let detail = resp.statusText;
try {
const body = await resp.json();
if (body.detail) detail = body.detail;
} catch (_) {}
throw new Error(detail);
}
return resp.json();
}
async function loadWeek() {
try {
const data = await fetchJSON("/api/week");
currentParity = data.parity;
} catch (_) {}
}
function formatDT(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function loadStatus() {
try {
return await fetchJSON("/api/status");
} catch (_) {
return null;
}
}
/* ---------- навигация по вкладкам ---------- */
function setActiveMenu(view) {
document.querySelectorAll(".side-menu a").forEach((a) => {
a.classList.toggle("active", a.dataset.view === view);
});
}
function fillOptions(list) {
groupSelect.innerHTML = "";
list.forEach((item) => {
const opt = document.createElement("option");
opt.value = item;
opt.textContent = item;
groupSelect.appendChild(opt);
});
}
async function setMode(next) {
mode = next;
localStorage.setItem("schedule-mode", next);
entityLabel.textContent = MODES[next].label;
setActiveMenu(next);
if (next === "rooms") {
entityLabel.textContent = "День";
groupSelect.classList.remove("hidden");
fillOptions(FREE_WEEKDAYS);
const savedDay = localStorage.getItem("schedule-room-day");
if (savedDay && FREE_WEEKDAYS.includes(savedDay)) groupSelect.value = savedDay;
await renderRoomsView(groupSelect.value);
return;
}
groupSelect.classList.remove("hidden");
const list = await fetchJSON(MODES[next].listUrl);
fillOptions(list);
if (!list.length) return;
const saved = localStorage.getItem(MODES[next].valueKey);
if (saved && list.includes(saved)) groupSelect.value = saved;
await renderView();
}
async function renderView(force = false) {
const value = groupSelect.value;
if (!value) return;
localStorage.setItem(MODES[mode].valueKey, value);
scheduleEl.innerHTML = "";
errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden");
try {
if (mode === "groups") {
const [data, status] = await Promise.all([
fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`),
loadStatus(),
]);
renderGroupSchedule(data, status);
} else {
const [data, status] = await Promise.all([
fetchJSON(`/api/teacher/${enc(value)}`),
loadStatus(),
]);
renderTeacher(data, status);
}
} catch (err) {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
} finally {
loadingEl.classList.add("hidden");
}
}
/* ---------- общие элементы ---------- */
function statusChip(label, value) {
const chip = document.createElement("div");
chip.className = "status-chip";
const lbl = document.createElement("span");
lbl.className = "status-label";
lbl.textContent = label;
const val = document.createElement("span");
val.className = "status-value";
val.textContent = value;
chip.appendChild(lbl);
chip.appendChild(val);
return chip;
}
function statusRowHTML(status) {
const row = document.createElement("div");
row.className = "schedule-status";
if (status) {
const modified = formatDT(status.table_modified);
const synced = formatDT(status.last_updated);
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
if (synced) row.appendChild(statusChip("Синхронизация", synced));
}
if (currentParity) {
const chip = document.createElement("div");
chip.className = "status-chip current-week";
const lbl = document.createElement("span");
lbl.className = "status-label";
lbl.textContent = "Сейчас";
const badge = document.createElement("span");
badge.className = "badge active " + (currentParity === "ч" ? "badge-ch" : "badge-zn");
badge.textContent = PARITY_LABEL[currentParity];
chip.appendChild(lbl);
chip.appendChild(badge);
row.appendChild(chip);
}
return row.childNodes.length ? row : null;
}
function makeDayBlock(day) {
const block = document.createElement("div");
block.className = "day-block" + (day === todayName() ? " today" : "");
const head = document.createElement("div");
head.className = "day-head";
head.innerHTML = `<span class="day-title">${day}</span>${day === todayName() ? '<span class="today-badge">сегодня</span>' : ""}`;
block.appendChild(head);
return block;
}
function parityColHTML(label, bodyHtml) {
return `<div class="parity-col">${bodyHtml}</div>`;
}
function alwaysColHTML(lessonsHtml) {
return `<div class="parity-col">${lessonsHtml}</div>`;
}
const OKNO_HTML = '<div class="lesson window">Окно</div>';
const FREE_HTML = '<div class="lesson window">Свободна</div>';
function addPeriodRow(block, num, time, lessonsHTML) {
const row = document.createElement("div");
row.className = "period";
row.innerHTML = `<div><div class="period-num">Пара ${num}</div><div class="period-time">${escapeHTML(time || "")}</div></div>`;
const col = document.createElement("div");
col.innerHTML = lessonsHTML;
row.appendChild(col);
block.appendChild(row);
}
function entityLessonHTML(parity, entry) {
const active = parity === currentParity ? " active" : "";
const details = [];
if (entry.room) details.push(entry.room);
if (entry.teacher) details.push(entry.teacher);
if (entry.groups && entry.groups.length) details.push(entry.groups.join(", "));
return `
<div class="lesson">
<div>
<span class="${PARITY_CLASS[parity]}${active}">${PARITY_LABEL[parity]}</span>
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
<span class="subject">${escapeHTML(entry.subject)}</span>
</div>
${details.length ? `<div class="details">${escapeHTML(details.join(" · "))}</div>` : ""}
${entry.note ? `<div class="lesson-note">${escapeHTML(entry.note)}</div>` : ""}
</div>`;
}
function singleLessonHTML(badgeKey, lesson, full) {
const config = {
always: { label: "Обе недели", cls: "badge badge-always" },
ч: { label: "Числитель", cls: "badge badge-ch" },
з: { label: "Знаменатель", cls: "badge badge-zn" },
};
const { label, cls } = config[badgeKey];
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
return `
<div class="lesson${full ? " full" : ""}">
<div>
<span class="${cls}${active}">${label}</span>
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
<span class="subject">${escapeHTML(lesson.subject)}</span>
</div>
${details ? `<div class="details">${escapeHTML(details)}</div>` : ""}
${lesson.note ? `<div class="lesson-note">${escapeHTML(lesson.note)}</div>` : ""}
</div>`;
}
/* ---------- рендер расписания группы ---------- */
function renderGroupSchedule(data, status) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: `Расписание группы ${data.group}`,
}));
if (data.semester_until) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-meta",
textContent: `Семестр до ${data.semester_until}`,
}));
}
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const daysContainer = document.createElement("div");
scheduleEl.appendChild(daysContainer);
let hasAnyLessons = false;
DAY_ORDER.forEach((day) => {
const periods = data.days[day];
if (!periods) return;
const block = makeDayBlock(day);
const rows = Object.entries(periods)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([num, p]) => {
const always = p.ч && p.з && p.ч.raw === p.з.raw;
return { num, p, always, hasLesson: Boolean(always || p.ч || p.з) };
});
const lastWithLesson = rows.reduce((acc, r, i) => (r.hasLesson ? i : acc), -1);
if (lastWithLesson === -1) {
block.appendChild(Object.assign(document.createElement("div"), {
className: "lesson empty",
textContent: "Занятий нет",
}));
daysContainer.appendChild(block);
return;
}
rows.slice(0, lastWithLesson + 1).forEach(({ num, p, always }) => {
let inner;
if (always) {
inner = `<div class="parities">${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}</div>`;
hasAnyLessons = true;
} else {
const chHtml = p.ч ? p.ч.lessons.map((l) => singleLessonHTML("ч", l)).join("") : null;
const znHtml = p.з ? p.з.lessons.map((l) => singleLessonHTML("з", l)).join("") : null;
if (!chHtml && !znHtml) {
inner = `<div class="parities">${parityColHTML("Числитель", OKNO_HTML)}</div>`;
} else {
inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
hasAnyLessons = true;
}
}
addPeriodRow(block, num, p.time, inner);
});
daysContainer.appendChild(block);
});
if (!hasAnyLessons) {
daysContainer.innerHTML = "";
daysContainer.appendChild(Object.assign(document.createElement("div"), {
className: "empty-day",
textContent: "Расписание пустое",
}));
}
}
const TEACHER_DAYS = DAY_ORDER.slice(0, 6);
/* ---------- рендер преподавателя ---------- */
function renderTeacher(data, status) {
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: `Преподаватель: ${data.full_name || data.name}`,
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const daysContainer = document.createElement("div");
scheduleEl.appendChild(daysContainer);
let hasAny = false;
TEACHER_DAYS.forEach((day) => {
const periods = data.days[day];
const occupied = periods && Object.values(periods).some((s) => (s.ч || []).length || (s.з || []).length);
const block = makeDayBlock(day);
if (!occupied) {
block.appendChild(Object.assign(document.createElement("div"), {
className: "lesson empty",
textContent: "Занятий нет",
}));
daysContainer.appendChild(block);
return;
}
Object.entries(periods)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([num, slot]) => {
const chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join("");
const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join("");
if (!chHtml && !znHtml) return;
const inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
addPeriodRow(block, num, slot.time, inner);
});
daysContainer.appendChild(block);
});
if (!hasAny) {
daysContainer.innerHTML = "";
daysContainer.appendChild(Object.assign(document.createElement("div"), {
className: "empty-day",
textContent: "Занятий нет",
}));
}
}
/* ---------- аудитории: занятость по выбранному дню ---------- */
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
async function renderRoomsView(day) {
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
scheduleEl.innerHTML = "";
errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden");
try {
const status = await loadStatus();
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: "Занятость аудиторий",
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const wrap = document.createElement("div");
wrap.className = "rooms-wrap";
scheduleEl.appendChild(wrap);
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`);
wrap.appendChild(roomsDayTable(data));
} catch (err) {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
} finally {
loadingEl.classList.add("hidden");
}
}
function roomsDayTable(data) {
const periodNums = [];
data.rooms.forEach((r) => {
Object.keys(r.periods || {}).forEach((p) => {
if (!periodNums.includes(p)) periodNums.push(p);
});
});
periodNums.sort((a, b) => Number(a) - Number(b));
return buildRoomsList(data, periodNums);
}
function buildRoomsList(data, periodNums) {
const list = document.createElement("div");
list.className = "rooms-list";
data.rooms.forEach((room) => {
const card = document.createElement("div");
card.className = "room-card";
const head = document.createElement("div");
head.className = "day-head";
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
card.appendChild(head);
const periods = room.periods || {};
periodNums.forEach((p) => {
const slot = periods[p] || {};
const row = document.createElement("div");
row.className = "room-row";
const left = document.createElement("div");
left.className = "room-row-num";
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
row.appendChild(left);
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
body.innerHTML = `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
}
row.appendChild(body);
card.appendChild(row);
});
list.appendChild(card);
});
return list;
}
function roomPeriodCell(slot) {
const td = document.createElement("td");
td.className = "r-cell";
const all = [...((slot && slot.ч) || []), ...((slot && slot.з) || [])];
if (!all.length) {
td.classList.add("r-free");
td.textContent = "окно";
return td;
}
td.classList.add("r-busy");
const seen = new Set();
const lines = [];
all.forEach((e) => {
if (!e.subject || seen.has(e.subject)) return;
seen.add(e.subject);
lines.push(e.subject);
});
lines.forEach((x) => {
const div = document.createElement("div");
div.className = "r-subj";
div.textContent = x;
td.appendChild(div);
});
td.title = lines.join("; ");
return td;
}
function escapeHTML(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}
function initTheme() {
const saved = localStorage.getItem("theme");
applyTheme(saved === "dark" ? "dark" : "light");
}
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
applyTheme(current === "dark" ? "light" : "dark");
});
initTheme();
document.querySelectorAll(".side-menu a").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
setMode(link.dataset.view).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
});
});
groupSelect.addEventListener("change", () => {
if (mode === "rooms") {
localStorage.setItem("schedule-room-day", groupSelect.value);
renderRoomsView(groupSelect.value).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
return;
}
renderView();
});
loadWeek().finally(() => {
const savedMode = localStorage.getItem("schedule-mode");
const initial = ["groups", "teachers", "rooms"].includes(savedMode) ? savedMode : "groups";
setMode(initial).catch((err) => {
errorEl.textContent = "Ошибка: " + err.message;
errorEl.classList.remove("hidden");
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание · Факультет ИВТ ЯрГУ</title>
<link rel="icon" type="image/svg+xml" href="/img/owl-white.svg">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="topbar">
<div class="topbar-brand">
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">
<img class="topbar-logo" src="/img/owl.svg" alt="ЯрГУ им. П.Г. Демидова">
</a>
<div class="topbar-text">
<a href="https://www.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-university">Ярославский государственный университет им. П.Г. Демидова</a>
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a>
</div>
</div>
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
<div class="layout">
<aside class="side">
<div class="side-head">
<a href="/" class="side-logo-link"><img class="side-logo" src="/img/owl-white.svg" alt="Факультет ИВТ"></a>
<div class="side-name">Расписание занятий</div>
</div>
<div class="side-nav">
<nav class="side-menu">
<a href="#" class="active" data-view="groups">Расписание групп</a>
<a href="#" data-view="teachers">Преподаватели</a>
<a href="#" data-view="rooms">Аудитории</a>
</nav>
<div class="side-label" id="entity-label">Группа</div>
<select id="group-select" aria-label="Выбор группы"></select>
</div>
<div class="side-footer">
<a href="https://vk.com/ivt7k" target="_blank" rel="noopener">Сообщество ВКонтакте</a>
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">Сайт факультета</a>
</div>
</aside>
<main>
<div id="error" class="error hidden"></div>
<div id="loading" class="loading hidden">Загрузка…</div>
<div id="schedule" class="schedule"></div>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,683 @@
:root {
--bg: #f1f5f9;
--surface: #ffffff;
--surface-2: #f7fafc;
--surface-3: #eef2f8;
--border: #dde5ef;
--border-2: #c3d1e2;
--text: #17223a;
--muted: #57677f;
--muted-2: #8c9bb0;
--nav-1: #1b2dd8;
--nav-2: #167cf2;
--accent: #0241e3;
--accent-blue: #1f4fd0;
--accent-red: #d01838;
--accent-red-bright: #e61d43;
--accent-blue-bg: #e5ecfc;
--on-accent: #ffffff;
--error-bg: #fbe9ea;
--error-border: #f3c1c5;
--error-text: #b00d15;
--shadow: rgba(10, 50, 110, 0.12);
--badge-ch-bg: #e3ebfc;
--badge-ch-text: #1f4fd0;
--badge-zn-bg: #fbe3e7;
--badge-zn-text: #c40f30;
--badge-kv-bg: #edf1f7;
--badge-kv-text: #43536b;
--badge-always-bg: #e6edf6;
--badge-always-text: #2e3d55;
--badge-ch-active-bg: linear-gradient(90deg, #1b2dd8, #167cf2);
--badge-ch-active-text: #ffffff;
--badge-zn-active-bg: #e61d43;
--badge-zn-active-text: #ffffff;
}
[data-theme="dark"] {
--bg: #0a1120;
--surface: #111a2e;
--surface-2: #16223a;
--surface-3: #1d2e4c;
--border: #25375a;
--border-2: #3a4f78;
--text: #e7eef8;
--muted: #96adca;
--muted-2: #647e9f;
--nav-1: #1d2f72;
--nav-2: #1358b0;
--accent: #5ba3f7;
--accent-blue: #86b8ff;
--accent-red: #e96a7e;
--accent-red-bright: #ff6b7f;
--accent-blue-bg: #1a2d52;
--on-accent: #ffffff;
--error-bg: #4d1c22;
--error-border: #7c2a34;
--error-text: #ffc2c9;
--shadow: rgba(0, 0, 0, 0.45);
--badge-ch-bg: #1a2c55;
--badge-ch-text: #8cb8ff;
--badge-zn-bg: #54222d;
--badge-zn-text: #ff9dae;
--badge-kv-bg: #1e2c43;
--badge-kv-text: #b6c6da;
--badge-always-bg: #23314d;
--badge-always-text: #d9e3f0;
--badge-ch-active-bg: #3d86f2;
--badge-ch-active-text: #ffffff;
--badge-zn-active-bg: #ff6479;
--badge-zn-active-text: #ffffff;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Roboto, -apple-system, "Segoe UI", Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-red); }
/* ---- Верхняя белая полоса с цветной совой (как на сайте) ---- */
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 8px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: sticky;
top: 0;
z-index: 20;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topbar-logo {
width: 46px;
height: 46px;
flex-shrink: 0;
}
.topbar-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.topbar-university {
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.topbar-university:hover { color: var(--accent-red); }
.topbar-faculty {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-faculty:hover { color: var(--accent-red); }
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--muted);
cursor: pointer;
flex-shrink: 0;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle .icon { width: 18px; height: 18px; }
.icon-sun { display: none; }
.icon-moon { display: block; }
[data-theme="dark"] .icon-sun { display: block; }
[data-theme="dark"] .icon-moon { display: none; }
/* ---- Двухколоночный layout: синяя панель + контент ---- */
.layout {
display: flex;
align-items: stretch;
min-height: calc(100vh - 63px);
}
.side {
width: 300px;
flex-shrink: 0;
background: linear-gradient(to bottom, var(--nav-1), var(--nav-2));
color: var(--on-accent);
display: flex;
flex-direction: column;
padding: 26px 22px 22px;
position: sticky;
top: 63px;
align-self: flex-start;
height: calc(100vh - 63px);
}
.side-head { text-align: center; padding-bottom: 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.22); }
.side-logo-link { display: inline-block; }
.side-logo { width: 92px; height: 92px; }
.side-name {
margin-top: 12px;
font-size: 17px;
font-weight: 600;
line-height: 1.25;
}
.side-nav {
padding: 18px 0;
flex: 1;
}
.side-menu {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
}
.side-menu a {
display: block;
color: rgba(255, 255, 255, 0.9);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
padding: 8px 10px;
border-left: 3px solid transparent;
}
.side-menu a:hover { color: #ffffff; background: rgba(0, 0, 0, 0.12); }
.side-menu a.active {
color: #ffffff;
background: rgba(0, 0, 0, 0.2);
border-left-color: #ffffff;
}
.side-label {
text-transform: uppercase;
font-size: 12px;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 8px;
}
#group-select {
width: 100%;
font-size: 16px;
font-weight: 500;
padding: 9px 10px;
border: none;
background: #ffffff;
color: #141c2b;
cursor: pointer;
}
#group-select option { background: #ffffff; color: #141c2b; }
[data-theme="dark"] #group-select {
background: rgba(10, 19, 40, 0.6);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.35);
}
[data-theme="dark"] #group-select option {
background: var(--surface-3);
color: var(--text);
}
.side-footer {
border-top: 1px solid rgba(255, 255, 255, 0.22);
padding-top: 14px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.side-footer a { color: rgba(255, 255, 255, 0.92); }
.side-footer a:hover { color: #ffffff; text-decoration: underline; }
main {
flex: 1;
min-width: 0;
padding: 26px 28px 40px;
}
.schedule { max-width: 900px; }
.schedule-title {
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 2px;
}
.schedule-title .badge { margin-left: 10px; font-size: 13px; vertical-align: middle; }
.schedule-meta { color: var(--muted); font-size: 14px; margin-bottom: 10px; }
.schedule-status {
display: flex;
justify-content: flex-start;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.status-chip {
flex: 1 1 0;
min-width: 170px;
min-height: 54px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
box-sizing: border-box;
}
.status-chip.current-week { align-items: flex-start; }
.status-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.status-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.current-week .badge { margin-right: 0; font-size: 12px; }
.day-block {
background: var(--surface);
border: 1px solid var(--border);
margin-bottom: 14px;
}
.day-block.today {
border-color: var(--accent-red-bright);
box-shadow: inset 3px 0 0 var(--accent-red-bright);
}
.day-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.day-title { font-size: 15px; font-weight: 700; text-transform: capitalize; color: var(--text); }
.today-badge {
background: var(--accent-red-bright);
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
letter-spacing: 0.4px;
}
.period {
padding: 8px 14px;
display: grid;
grid-template-columns: 96px 1fr;
gap: 14px;
border-bottom: 1px solid var(--border);
}
.period:last-child { border-bottom: none; }
.period-num { font-weight: 700; color: var(--text); }
.period-time { font-size: 13px; color: var(--muted); margin-top: 2px; }
.parities {
display: flex;
gap: 10px;
align-items: stretch;
}
.parity-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.parity-col .lesson {
margin-bottom: 0;
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.parity-col .window { justify-content: center; text-align: center; }
@media (max-width: 1023px) {
.parities { flex-direction: column; }
}
.lesson {
border: 1px solid var(--border);
padding: 7px 11px;
background: var(--surface-2);
}
.lesson:hover { border-color: var(--accent); }
.lesson .subject { font-weight: 700; font-size: 14px; }
.lesson .details { font-size: 13px; color: var(--muted); margin-top: 1px; }
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
margin-right: 6px;
vertical-align: 1px;
}
.badge-ch { background: var(--badge-ch-bg); color: var(--badge-ch-text); }
.badge-zn { background: var(--badge-zn-bg); color: var(--badge-zn-text); }
.badge-kv { background: var(--badge-kv-bg); color: var(--badge-kv-text); }
.badge-always { background: var(--badge-always-bg); color: var(--badge-always-text); }
.badge-ch.active { background: var(--badge-ch-active-bg); color: var(--badge-ch-active-text); }
.badge-zn.active { background: var(--badge-zn-active-bg); color: var(--badge-zn-active-text); }
.lesson.full { grid-column: 1 / -1; }
.empty-day { color: var(--muted-2); font-size: 14px; padding: 10px 14px; }
.lesson.empty {
grid-column: 1 / -1;
text-align: center;
color: var(--muted);
background: var(--surface-3);
border: 1px dashed var(--border-2);
font-weight: 600;
padding: 10px;
}
.window {
border-style: dashed;
color: var(--muted-2);
background: var(--surface);
text-align: center;
}
/* ---- поиск свободной аудитории ---- */
.free-widget {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 12px 14px;
margin-bottom: 18px;
}
.free-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.free-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.free-hint { font-size: 13px; color: var(--muted); }
.free-controls select {
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text);
}
.free-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
}
.free-item {
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
font-family: inherit;
}
.free-item:hover { border-color: var(--accent); }
.free-room { font-weight: 700; }
.free-info { font-size: 12px; color: var(--muted); }
.free-loading, .free-none { color: var(--muted); font-size: 14px; padding: 6px 0; }
.error {
background: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-border);
padding: 12px 16px;
margin-bottom: 16px;
}
.loading { color: var(--muted); text-align: center; padding: 30px; }
.hidden { display: none; }
/* ---- Мобильная версия: панель сверху ---- */
@media (max-width: 860px) {
.layout { flex-direction: column; }
.side {
width: 100%;
position: static;
height: auto;
padding: 14px 16px;
flex-direction: row;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.side-head {
text-align: left;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 0;
border-bottom: none;
}
.side-logo { width: 52px; height: 52px; }
.side-name { margin-top: 0; font-size: 15px; }
.side-nav { padding: 0; flex: 1; min-width: 180px; }
.side-label { display: none; }
.side-footer { display: none; }
.side-menu {
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 0;
}
.side-menu a {
border-left: none;
border-bottom: 2px solid transparent;
padding: 6px 8px;
font-size: 13px;
}
.side-menu a.active { border-bottom-color: #ffffff; }
main { padding: 16px 12px 30px; }
.period { grid-template-columns: 80px 1fr; gap: 10px; }
}
/* ---- занятость аудиторий по дню ---- */
.rooms-controls {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.rooms-controls select {
font-size: 15px;
padding: 6px 10px;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text);
}
.rooms-scroll { overflow-x: auto; }
.rooms-table {
border-collapse: collapse;
width: 100%;
min-width: 640px;
table-layout: fixed;
background: var(--surface);
font-size: 13px;
}
.rooms-table th:first-child,
.rooms-table td:first-child { width: 170px; }
.rooms-table th,
.rooms-table td {
border: 1px solid var(--border);
padding: 6px 9px;
text-align: left;
vertical-align: top;
}
.rooms-table th {
background: var(--surface-3);
font-size: 12px;
font-weight: 700;
color: var(--text);
white-space: nowrap;
}
.r-name {
font-weight: 700;
width: 170px;
overflow-wrap: anywhere;
}
.r-name-info { font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.35; }
.r-cell { min-width: 90px; }
.r-cell.r-free {
background: var(--surface);
color: var(--muted-2);
text-align: center;
}
.r-cell.r-busy { background: var(--badge-ch-bg); }
.r-subj { font-weight: 600; color: var(--text); line-height: 1.3; }
/* ---- аудитории: список карточками (мобильная версия) ---- */
.rooms-list { display: flex; flex-direction: column; }
.room-card {
background: var(--surface);
border: 1px solid var(--border);
padding: 14px;
margin-bottom: 12px;
}
.room-title { line-height: 1.3; }
.room-info {
color: var(--muted);
font-size: 12px;
margin-top: 2px;
}
.room-row {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--border);
align-items: center;
}
.room-row-num { font-weight: 700; color: var(--text); }
.room-row-body { min-width: 0; }
.room-lesson {
font-weight: 600;
font-size: 14px;
color: var(--text);
margin-bottom: 6px;
}
.room-lesson:last-child { margin-bottom: 0; }
.lesson-note {
color: var(--accent-red-bright);
font-size: 12px;
font-weight: 600;
margin-top: 4px;
white-space: pre-line;
}