- 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>
84 lines
3.9 KiB
JavaScript
84 lines
3.9 KiB
JavaScript
// 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;
|
||
}
|