Files
psu-schedule/server/places.js
Zakhar Timoshenko 10c85e3bb9 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>
2026-10-21 01:26:54 +03:00

84 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}