feat: read "Занятия в Полоцке" as a day's town, show it in all clients

- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 01:26:54 +03:00
parent 3d08d0cb70
commit 10c85e3bb9
50 changed files with 808 additions and 80 deletions

View File

@@ -311,6 +311,7 @@ export function dayOf(idx, calendar, { teacher, room }, date) {
teachers,
rooms,
moved: movedOn(entry, date),
place: l.place || null,
distance: entry.distance,
groups: [],
documents: [],

View File

@@ -78,8 +78,9 @@ app.use("/api", (_req, res, next) => {
// 10 — parserVersion in /api/catalog;
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health;
// 12 — a JSON log line per request;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions.
const API_VERSION = 13;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
const API_VERSION = 14;
/**
* What the server still waits for before it answers from memory: the catalog, the week table

View File

@@ -8,8 +8,9 @@ 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";
import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile);
export const PARSER_VERSION = 13;
export const PARSER_VERSION = 14;
const days = [
"понедельник",
"вторник",
@@ -66,7 +67,9 @@ function conditionIn(value) {
);
return range?.[0].trim() || "";
}
export function details(raw) {
export function details(text) {
// "Новополоцк" on a line of its own, "246(Новополоцк)": the town is no part of the subject or room.
const { place, rest: raw } = lessonPlace(text);
const condition = conditionIn(raw);
let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
@@ -106,7 +109,7 @@ export function details(raw) {
// A long note under a capitalised heading ("ВВЕДЕНИЕ В ВЫСШЕЕ ОБРАЗОВАНИЕ / г. Полоцк, …"):
// the heading names it; the whole text stays in the details.
const heading = rest.split("\n")[0].trim();
if (subject === rest && rest.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
if (subject === rest && text.split("\n").length >= 3 && /[А-ЯЁ]{4}/u.test(heading) && !/[а-яё]/u.test(heading)) subject = heading;
const defence = /^защита/i.test(leading?.[1] || "");
const token =
kind?.[1].replace(/[.\s/]/g, "").toLowerCase() ||
@@ -148,6 +151,7 @@ export function details(raw) {
: "Занятие",
condition,
...(own && { ownSubgroup: Number(own[1]) }),
...(place && { place }),
};
}
// The kind of a lesson in brackets: "(л.)", "(п.з.)", "(уст.л.)", "(конс.)".
@@ -394,6 +398,8 @@ const isoDate = (m) => `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")
export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer);
const lessons = [],
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
places = [],
groups = new Set(),
warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
@@ -548,6 +554,28 @@ export function parseDocx(buffer, source = {}) {
const spansBoth =
cell.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
const columnSubgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
// "Занятия в Полоцке" over the day's first row is no lesson: the column's lessons that day
// are in that town. A lesson may follow it in the same cell.
const note = placeNote(raw);
if (note) {
places.push({ day, date, groups: targetGroups, subgroups: columnSubgroups, place: note.place, text: note.text });
raw = note.rest;
if (!raw.trim()) return;
}
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").
@@ -571,20 +599,7 @@ export function parseDocx(buffer, source = {}) {
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
if (oneDate) data.condition = lead.text;
}
const subgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
const subgroups = [...columnSubgroups];
// A subgroup named in the cell ("2п/гр.") when the column does not tell one.
const own = data.ownSubgroup;
delete data.ownSubgroup;
@@ -677,12 +692,22 @@ export function parseDocx(buffer, source = {}) {
warnings.add(
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
);
// A lesson on a day the group spends in another town is there too, unless its cell says otherwise.
const labels = new Map();
for (const l of lessons)
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
for (const lesson of lessons) {
if (lesson.place || !places.length) continue;
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
if (place) lesson.place = place;
}
return {
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
groups: [...groups],
lessons: lessons.sort(
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
),
...(places.length && { places }),
notes: document.notes,
warnings: [...warnings],
source: {

83
server/places.js Normal file
View File

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

View File

@@ -2,6 +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 { placesOn } from "../shared/places.js";
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
import { apiError } from "./errors.js";
@@ -24,6 +25,8 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
teachers: date ? teachersOn(people, week) : people.teachers,
rooms,
roomMoved: Boolean(date && movedOn(people, date)),
// "Полоцк": the town, when the cell or the day's note names one.
place: lesson.place || null,
distance: people.distance,
condition: lesson.condition,
variant: lesson.variant,
@@ -58,6 +61,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
}
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
const labels = subgroupsOf(schedule, group);
// A session schedule printed with a date on every day needs no week table: its own dates
// say when each lesson takes place, even in a year the table no longer covers.
const sessionDates = lessons.every((l) => l.date || l.dates?.length)
@@ -112,11 +116,14 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
if (entry.lesson.date === date || entry.lesson.dates?.includes(date))
dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar));
dayLessons.sort((a, b) => a.start.localeCompare(b.start));
// A day without lessons (a holiday, weeks of practice) is in no town.
const places = dayLessons.length ? placesOn(schedule.places, group, labels, weekday(date), date) : [];
out.push({
date,
weekday: weekday(date),
week: week ? { n: week.n, color: week.color } : null,
lessons: dayLessons,
...(places.length && { places }),
});
}
const weekly = [1, 2, 3, 4, 5, 6, 7]
@@ -127,7 +134,11 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
.sort((a, b) => a.start.localeCompare(b.start)),
}))
.filter((d) => d.lessons.length);
.filter((d) => d.lessons.length)
.map((d) => {
const places = placesOn(schedule.places, group, labels, d.day);
return places.length ? { ...d, places } : d;
});
return {
group,
document: {