Add course schedules for ISF and ITZS programs for 2026 and 2024 sessions
- Created new snapshot files for ISF credits and daily schedules for the 26-АРХ and 26-ПГС programs. - Added summer 2026 and winter 2024 exam schedules for the 23-ИТзс program, detailing lectures, labs, and consultations.
This commit is contained in:
125
server/parser.js
125
server/parser.js
@@ -6,9 +6,9 @@ import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseWeekRanges, parseTextDates, DATE_LIST } from "../shared/weeks.js";
|
||||
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 11;
|
||||
export const PARSER_VERSION = 12;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -46,10 +46,15 @@ function timesIn(value) {
|
||||
? { start: times[0], end: times.at(-1), original: value }
|
||||
: null;
|
||||
}
|
||||
// One part of a week mark, "6-12" or "2,4", with its optional "н."; a mark is several parts
|
||||
// joined by ";" or "," ("1-4н.;6-12н.", "2-5 н., 10-17 н.", "1-9;15,16н.") ending in "н".
|
||||
const WEEK_PART = String.raw`\d{1,2}(?:\s*[-–,]\s*\d{1,2})*(?:\s*н(?:ед)?\.?)?`;
|
||||
const WEEK_MARK = new RegExp(
|
||||
String.raw`^(?:(?:с|со)\s*)?${WEEK_PART}(?:\s*[;,]\s*${WEEK_PART})*(?<=н|нед|н\.|нед\.)(?:\s*\.)?\s*`,
|
||||
"i",
|
||||
);
|
||||
function conditionIn(value) {
|
||||
const m = value.match(
|
||||
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)(?:\s*\.)?\s*/i,
|
||||
);
|
||||
const m = value.match(WEEK_MARK);
|
||||
if (m) return m[0].trim();
|
||||
// Some university cells omit the week suffix (for example 1-8,10-17).
|
||||
const range = value.match(
|
||||
@@ -131,11 +136,7 @@ export function details(raw) {
|
||||
}
|
||||
// Split only at an explicit new week condition. Keep unfamiliar wording intact.
|
||||
function alternatives(raw) {
|
||||
return raw
|
||||
.split(
|
||||
/\n(?=(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)/i,
|
||||
)
|
||||
.filter(Boolean);
|
||||
return raw.split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)})`, "i")).filter(Boolean);
|
||||
}
|
||||
export function readDocx(buffer) {
|
||||
let zip;
|
||||
@@ -226,6 +227,43 @@ export function readDocx(buffer) {
|
||||
* columns is "Д.н." (the weekday with its date), "Время" and "Дисциплина, преподаватель,
|
||||
* аудитория", whose columns split in two where the subgroups have separate laboratory classes.
|
||||
*/
|
||||
/**
|
||||
* A date that opens a cell: "13.10 Химия", "3.12 Физика", "24.11. Графика", "12.09.26г. Алгебра",
|
||||
* "09.09.Материалы". A one-digit day only below 7, so "9.10" stays ambiguous rather than a date.
|
||||
*/
|
||||
export function leadingDate(text, yearStart) {
|
||||
const m = text.match(/^(\d{1,2})\.(\d{2})(?:\.(\d{4}|\d{2})(?!\d))?\.?(?:\s*г\.?)?(?=\s|[А-ЯЁа-яё])/u);
|
||||
if (!m) return null;
|
||||
const day = Number(m[1]), month = Number(m[2]);
|
||||
if (day < 1 || day > 31 || month < 1 || month > 12 || (m[1].length === 1 && day >= 7)) return null;
|
||||
const year = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : yearStart ? (month >= 8 ? Number(yearStart) : Number(yearStart) + 1) : null;
|
||||
return {
|
||||
text: m[0].trim(),
|
||||
length: m[0].length,
|
||||
date: year ? `${year}-${m[2]}-${m[1].padStart(2, "0")}` : null,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Times of their own that open a cell, for a curator's hour or a late pair: "16.50 Кураторский час",
|
||||
* "9.00 Информационный час", "18.35. Налоговое право", "11:10-11:30 – информационный час",
|
||||
* "17.00 и 18.35 Русский язык". With `loose`, also "20.10", which reads as a date as well.
|
||||
*/
|
||||
export function leadingTime(text, loose = false) {
|
||||
const hm = (h, m) => `${h.padStart(2, "0")}:${m}`;
|
||||
const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s)/);
|
||||
if (range && Number(range[1]) <= 23 && Number(range[3]) <= 23)
|
||||
return { starts: [hm(range[1], range[2])], end: hm(range[3], range[4]), length: range[0].length };
|
||||
const T = String.raw`[0-2]?\d[.:][0-5]\d`;
|
||||
const list = text.match(new RegExp(String.raw`^${T}(?:\s*(?:и|\+|,)\s*${T})*\.?(?=\s|[А-ЯЁа-яё])`, "u"));
|
||||
if (!list) return null;
|
||||
const times = [...list[0].matchAll(/([0-2]?\d)([.:])([0-5]\d)/g)];
|
||||
// One unmistakable time ("18.35+20.10") vouches for the others in the list.
|
||||
const clear = ([, , sep, m]) => sep === ":" || Number(m) > 12 || m === "00";
|
||||
if (!loose && !times.some(clear)) return null;
|
||||
// Lessons run from 7 to 22: "3.12" next to a clear time is still not a time.
|
||||
if (times.some((t) => Number(t[1]) > 23 || (!clear(t) && (Number(t[1]) < 7 || Number(t[1]) > 21)))) return null;
|
||||
return { starts: times.map(([, h, , m]) => hm(h, m)), end: null, length: list[0].length };
|
||||
}
|
||||
function weekBlocks(rows) {
|
||||
for (let i = 0; i < Math.min(rows.length, 4); i++) {
|
||||
const cells = rows[i];
|
||||
@@ -411,43 +449,25 @@ export function parseDocx(buffer, source = {}) {
|
||||
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
||||
for (const alternative of alternatives(raw)) {
|
||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject.
|
||||
const data = details(alternative.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
const dateCandidate = alternative.match(
|
||||
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
|
||||
);
|
||||
const singleDate =
|
||||
dateCandidate &&
|
||||
Number(dateCandidate[1]) >= 1 &&
|
||||
Number(dateCandidate[1]) <= 31 &&
|
||||
Number(dateCandidate[2]) >= 1 &&
|
||||
Number(dateCandidate[2]) <= 12
|
||||
? dateCandidate
|
||||
: null;
|
||||
const explicitTime = alternative.match(
|
||||
/^([0-2]?\d)([.:])([0-5]\d)\s+/,
|
||||
);
|
||||
const ownTime =
|
||||
explicitTime &&
|
||||
Number(explicitTime[1]) <= 23 &&
|
||||
(explicitTime[2] === ":" || Number(explicitTime[3]) > 12)
|
||||
? `${explicitTime[1].padStart(2, "0")}:${explicitTime[3]}`
|
||||
: null;
|
||||
const oneDate =
|
||||
singleDate && (singleDate[3] || academicYear)
|
||||
? `${singleDate[3] || academicYear}-${singleDate[2]}-${singleDate[1]}`
|
||||
: null;
|
||||
if (singleDate) {
|
||||
data.condition = singleDate[0].trim();
|
||||
data.subject = details(
|
||||
alternative.slice(singleDate[0].length),
|
||||
).subject;
|
||||
const read = (text) => {
|
||||
const data = details(text.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
return data;
|
||||
};
|
||||
let data = read(alternative);
|
||||
// "20.10" is 20 October or the eighth pair at 20:10. A date on another weekday than
|
||||
// the row, or followed by a week mark, is the time.
|
||||
let lead = leadingTime(alternative) || leadingDate(alternative, yearStart);
|
||||
if (lead?.date) {
|
||||
const after = alternative.slice(lead.length).trim();
|
||||
const time = leadingTime(alternative, true);
|
||||
if (time && (weekday(lead.date) !== day || WEEK_MARK.test(after))) lead = time;
|
||||
}
|
||||
if (ownTime) {
|
||||
data.subject = details(
|
||||
alternative.slice(explicitTime[0].length),
|
||||
).subject;
|
||||
data.condition = "";
|
||||
const oneDate = lead?.date || null;
|
||||
if (lead) {
|
||||
// The rest of the cell is an ordinary entry: "17.00 1 нед. Математика (пр.) …".
|
||||
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
|
||||
if (oneDate) data.condition = lead.text;
|
||||
}
|
||||
const subgroups = sessionColumns
|
||||
? []
|
||||
@@ -463,17 +483,18 @@ export function parseDocx(buffer, source = {}) {
|
||||
group: r.group,
|
||||
label: `Подгруппа ${cell.col - r.col + 1}`,
|
||||
}));
|
||||
const id = createHash("sha256")
|
||||
.update([source.name, i, cell.col, alternative].join("|"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
// "17.00 и 18.35 Русский язык": one lesson at each time.
|
||||
for (const [n, start] of (lead?.starts || [null]).entries())
|
||||
lessons.push({
|
||||
id,
|
||||
id: createHash("sha256")
|
||||
.update([source.name, i, cell.col, alternative, n || ""].join("|"))
|
||||
.digest("hex")
|
||||
.slice(0, 16),
|
||||
day,
|
||||
date: date || oneDate,
|
||||
pair: pair || null,
|
||||
...time,
|
||||
...(ownTime ? { start: ownTime, end: null } : {}),
|
||||
...(start && { start, end: lead.end }),
|
||||
...data,
|
||||
...overrides,
|
||||
groups: targetGroups,
|
||||
|
||||
Reference in New Issue
Block a user