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

@@ -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: {