feat: Add skeleton loading UI components for various screens
- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects. - Introduced `Undo.kt` to manage undo actions with a snackbar interface. - Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable. - Added various splash animations in the `animator` directory for a smooth launch experience. - Designed drawable resources for the widget and splash screen, including icons for city and room moves. - Defined XML resources for widget configuration and photo paths.
This commit is contained in:
@@ -80,7 +80,9 @@ app.use("/api", (_req, res, next) => {
|
||||
// 12 — a JSON log line per request;
|
||||
// 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;
|
||||
// 15 — a lesson's `option` and the group's `choices` (German or French for parts of a group),
|
||||
// a lesson's `audience` (foreign students or the others) and the group's `audience` flag.
|
||||
const API_VERSION = 15;
|
||||
|
||||
/**
|
||||
* What the server still waits for before it answers from memory: the catalog, the week table
|
||||
|
||||
134
server/parser.js
134
server/parser.js
@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
|
||||
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
|
||||
import { placeNote, lessonPlace, placeOf } from "./places.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 15;
|
||||
export const PARSER_VERSION = 17;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -206,17 +206,89 @@ function byKind(text) {
|
||||
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
|
||||
}
|
||||
|
||||
// Who a lesson is for: "(для иностранных студентов)", "Русский язык как иностранный" are for
|
||||
// foreign students only, "(кроме иностр.студ.)" for the others.
|
||||
const FOR_FOREIGN = /\(\s*для\s+иностр[^)]*\)/iu;
|
||||
const NOT_FOREIGN = /\(\s*кроме\s+иностр[^)]*\)/iu;
|
||||
const FOREIGN_SUBJECT = /как\s+иностранн/iu;
|
||||
export function audienceOf(text) {
|
||||
if (NOT_FOREIGN.test(text)) return "local";
|
||||
if (FOR_FOREIGN.test(text) || FOREIGN_SUBJECT.test(text)) return "foreign";
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* One cell, two lessons at once: Russian for foreign students while the others have another
|
||||
* lesson. "Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных
|
||||
* студентов) Семченко Е.Д. ауд.313н" or "Русский язык как иностранный (п.з.) Немирская В.А. 331Е
|
||||
* ФИЗИЧЕСКАЯ КУЛЬТУРА". A line with only the teacher below the lesson ("Хаткевич Т.В. ауд. 323н")
|
||||
* is no lesson of its own.
|
||||
*/
|
||||
// "Новополоцк" or "17.00" before a lesson is its town or time, not another lesson.
|
||||
const aLesson = (text) =>
|
||||
/(?:[А-ЯЁа-яё]\s?){3}/u.test(lessonPlace(text).rest.replace(/\d{1,2}[.:]\d{2}|(?:г\.\s*)?(?:ново)?полоцк[а-яё]*/giu, ""));
|
||||
export function byAudience(text) {
|
||||
const lines = text.split("\n");
|
||||
const at = lines.findIndex(
|
||||
(line, i) => i > 0 && audienceOf(line) === "foreign" && !/^\s*[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u.test(line),
|
||||
);
|
||||
const head = lines.slice(0, at).join("\n");
|
||||
if (at > 0 && audienceOf(head) !== "foreign" && aLesson(head))
|
||||
return [
|
||||
{ text: lines.slice(0, at).join("\n").trim(), audience: "local" },
|
||||
{ text: lines.slice(at).join("\n").trim(), audience: "foreign" },
|
||||
];
|
||||
// "… Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА": the others' lesson in capitals after it.
|
||||
const tail = text.match(
|
||||
/^([\s\S]*?(?:\d{2,3}\s*[А-ЯЁа-яё]?|[А-ЯЁ]\.))\s+((?:(?:с|со)\s*)?(?:\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s+)?[А-ЯЁ]{3,}(?:\s+[А-ЯЁ]{3,})+)\s*$/u,
|
||||
);
|
||||
if (tail && audienceOf(tail[1]) === "foreign" && !audienceOf(tail[2]))
|
||||
return [
|
||||
{ text: tail[1].trim(), audience: "foreign" },
|
||||
{ text: tail[2].trim(), audience: "local" },
|
||||
];
|
||||
// "Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный …" on one line.
|
||||
const inline = text.match(/^((?:[А-ЯЁа-яё]\s?){3}[^\n]*?)\s+((?:\d{2}-[^\s:]+:\s*)?Русский\s+язык\s+как\s+иностранн[\s\S]*)$/u);
|
||||
if (inline && !audienceOf(inline[1]) && aLesson(inline[1]))
|
||||
return [
|
||||
{ text: inline[1].trim(), audience: "local" },
|
||||
{ text: inline[2].trim(), audience: "foreign" },
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
// "25-ДОи", "24-ТНГи-2": a group of foreign students.
|
||||
const FOREIGN_GROUP = /[А-ЯЁа-яё]и(?:-\d+)?$/u;
|
||||
// A line that is a whole lesson: a subject, its kind in brackets and a teacher.
|
||||
const WHOLE_LESSON = /^\s*[А-ЯЁ][^\n]*?\((?:л|п\.?\s*з|л\.?\s*з|лаб|пр|сем)\.?\)[^\n]*?[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u;
|
||||
/**
|
||||
* "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е":
|
||||
* one cell over the whole group, two lessons at once for the parts of the group that study one
|
||||
* or the other. A line without a subject of its own ("321Д") stays with the lesson above.
|
||||
*/
|
||||
export function parallel(text) {
|
||||
const parts = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!parts.length || WHOLE_LESSON.test(line)) parts.push(line);
|
||||
else parts[parts.length - 1] += "\n" + line;
|
||||
}
|
||||
if (parts.length < 2 || !parts.every((p) => WHOLE_LESSON.test(p))) return [text];
|
||||
return parts.map((p) => p.trim().replace(/[,;]+$/, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
|
||||
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
|
||||
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
|
||||
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
|
||||
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
|
||||
* other weeks. A stray "." is no lesson.
|
||||
* other weeks. A stray "." is no lesson. Lessons side by side for parts of the group ([parallel])
|
||||
* also carry `choice`: the subjects to choose from.
|
||||
*/
|
||||
export function entries(raw) {
|
||||
const out = [];
|
||||
for (const alternative of alternatives(raw).flatMap(byKind)) {
|
||||
for (const part of alternatives(raw).flatMap(byKind)) {
|
||||
// "…Третьяк З.И. 331Е,\n2-13н. …": the comma before the next lesson is not part of the room.
|
||||
const alternative = part.replace(/[,;]\s*$/, "");
|
||||
if (!/[\p{L}\d]/u.test(alternative)) continue;
|
||||
const prev = out.at(-1);
|
||||
if (prev && onlyPlace(alternative)) {
|
||||
@@ -238,6 +310,24 @@ export function entries(raw) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const options = parallel(alternative);
|
||||
// "Теория языка (кроме иностр.студ.) … / История языка (для иностр.студ.) …": not a choice,
|
||||
// each lesson is for its own students.
|
||||
const marks = options.map(audienceOf);
|
||||
if (options.length > 1 && marks.every(Boolean)) {
|
||||
options.forEach((o, i) => out.push({ text: o, main: o, plan: [], audience: marks[i] }));
|
||||
continue;
|
||||
}
|
||||
const split = byAudience(alternative);
|
||||
if (split) {
|
||||
for (const p of split) out.push({ text: p.text, main: p.text, plan: weekPlan(p.text).filter((w) => w.color), audience: p.audience });
|
||||
continue;
|
||||
}
|
||||
if (options.length > 1) {
|
||||
const choice = options.map((o) => details(o).subject);
|
||||
for (const o of options) out.push({ text: o, main: o, plan: [], choice });
|
||||
continue;
|
||||
}
|
||||
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
|
||||
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
|
||||
}
|
||||
@@ -400,6 +490,8 @@ export function parseDocx(buffer, source = {}) {
|
||||
const lessons = [],
|
||||
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
|
||||
places = [],
|
||||
// Subjects that parts of a group choose between: the second foreign language, an elective.
|
||||
choices = [],
|
||||
groups = new Set(),
|
||||
warnings = new Set();
|
||||
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
|
||||
@@ -576,11 +668,23 @@ export function parseDocx(buffer, source = {}) {
|
||||
raw = note.rest;
|
||||
if (!raw.trim()) return;
|
||||
}
|
||||
for (const { text: whole, main: alternative, plan } of entries(raw)) {
|
||||
for (const { text: whole, main, plan, choice, audience } of entries(raw)) {
|
||||
if (choice) choices.push({ groups: targetGroups, options: choice });
|
||||
// "24-КФ: Русский язык как иностранный": one of the cell's groups.
|
||||
let alternative = main;
|
||||
let lessonGroups = targetGroups;
|
||||
const named = main.match(/^\s*([^:\n]{4,24}):\s+(?=\S)/u);
|
||||
const only = named ? groupsIn(named[1]) : [];
|
||||
if (only.length && only.every((g) => targetGroups.includes(g))) {
|
||||
lessonGroups = only;
|
||||
alternative = main.slice(named[0].length);
|
||||
}
|
||||
// "Единый день информирования 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").
|
||||
const read = (text) => {
|
||||
const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
// "(для иностранных студентов)" is who the lesson is for, no part of its name or room.
|
||||
const plain = text.replace(FOR_FOREIGN, " ").replace(NOT_FOREIGN, " ");
|
||||
const data = details(roomMoves(plain).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
return data;
|
||||
};
|
||||
@@ -618,7 +722,7 @@ export function parseDocx(buffer, source = {}) {
|
||||
...(start && { start, end: lead.end }),
|
||||
...data,
|
||||
...overrides,
|
||||
groups: targetGroups,
|
||||
groups: lessonGroups,
|
||||
subgroups,
|
||||
variant: spansBoth ? "all" : variant,
|
||||
weekRanges:
|
||||
@@ -633,6 +737,10 @@ export function parseDocx(buffer, source = {}) {
|
||||
? cell.color
|
||||
: null,
|
||||
raw: whole,
|
||||
...(choice && { option: data.subject }),
|
||||
// Groups of foreign students alone ("25-ДОи") have Russian as everyone's lesson.
|
||||
...((audience || audienceOf(whole)) &&
|
||||
!lessonGroups.every(FOREIGN_GROUP.test.bind(FOREIGN_GROUP)) && { audience: audience || audienceOf(whole) }),
|
||||
// Rooms and teachers by week, or by the week's colour.
|
||||
...(plan.length && { plan }),
|
||||
});
|
||||
@@ -696,6 +804,19 @@ export function parseDocx(buffer, source = {}) {
|
||||
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]));
|
||||
// The chosen subject on its own in a cell ("Немецкий язык" on Thursday) is the same choice:
|
||||
// only that part of the group has it.
|
||||
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
const sets = [];
|
||||
for (const c of choices) {
|
||||
const key = c.groups.join("|") + "#" + c.options.map(norm).sort().join("|");
|
||||
if (!sets.some((s) => s.key === key)) sets.push({ key, groups: c.groups, options: c.options });
|
||||
}
|
||||
for (const lesson of lessons) {
|
||||
if (lesson.option || lesson.subgroups.length) continue;
|
||||
const set = sets.find((s) => s.groups.some((g) => lesson.groups.includes(g)) && s.options.some((o) => norm(o) === norm(lesson.subject)));
|
||||
if (set) lesson.option = set.options.find((o) => norm(o) === norm(lesson.subject));
|
||||
}
|
||||
for (const lesson of lessons) {
|
||||
if (lesson.place || !places.length) continue;
|
||||
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
|
||||
@@ -708,6 +829,7 @@ export function parseDocx(buffer, source = {}) {
|
||||
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
|
||||
),
|
||||
...(places.length && { places }),
|
||||
...(sets.length && { choices: sets.map(({ groups, options }) => ({ groups, options })) }),
|
||||
notes: document.notes,
|
||||
warnings: [...warnings],
|
||||
source: {
|
||||
|
||||
@@ -31,6 +31,10 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
|
||||
condition: lesson.condition,
|
||||
variant: lesson.variant,
|
||||
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
|
||||
// "Немецкий язык" when part of the group studies German and part French (see `choices`).
|
||||
option: lesson.option || null,
|
||||
// "foreign": for foreign students only (Russian as a foreign language); "local": for the others.
|
||||
audience: lesson.audience || null,
|
||||
groups: lesson.groups,
|
||||
uncertain: status === "maybe",
|
||||
raw: lesson.raw,
|
||||
@@ -159,6 +163,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean),
|
||||
]),
|
||||
].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })),
|
||||
// What parts of the group choose between: [["Немецкий язык", "Французский язык"]].
|
||||
choices: (schedule.choices || []).filter((c) => c.groups.includes(group)).map((c) => c.options),
|
||||
// Some lessons are for foreign students only, or for the others: worth asking which one you are.
|
||||
audience: lessons.some((l) => l.audience),
|
||||
bells: calendar?.bells || [],
|
||||
from,
|
||||
days: out,
|
||||
|
||||
Reference in New Issue
Block a user