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:
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
83
server/places.js
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user