Files
psu-schedule/server/source.js
2026-10-16 22:08:45 +03:00

401 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { XMLParser } from "fast-xml-parser";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { parseWeeksDocx, parseBellsDocx } from "./calendar.js";
import {
academicStartYear,
assignSemesters,
todayIso,
weekIndexOf,
} from "../shared/weeks.js";
const run = promisify(execFile);
const ORIGIN = "https://nextcloud.psu.by";
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
const YEAR_FOLDER = /^(\d{4})-(\d{4})$/;
const DATA = fileURLToPath(new URL("../.data/", import.meta.url));
const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false });
const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []);
const DISCOVERY_TTL = 15 * 60 * 1000,
CATALOG_TTL = 15 * 60 * 1000,
CALENDAR_TTL = 6 * 60 * 60 * 1000;
let catalogue, pending, calendar, calendarPending, sources, sourcesPending;
const status = { catalogError: null, calendarError: null };
export async function request(path, method = "GET") {
// Only the university share is reachable; listings and catalog entries supply the paths.
if (
!path.startsWith(SHARE) ||
/[\r\n]/.test(path) ||
/(^|\/)\.\.(\/|$)/.test(decodeURIComponent(path)) ||
!["GET", "PROPFIND"].includes(method)
)
throw new Error("Недопустимый путь документа.");
const url = ORIGIN + path;
// curl also works with the system trust store on macOS. Certificate checks stay enabled.
const args = [
"--fail",
"--silent",
"--show-error",
"--max-time",
"25",
"--max-filesize",
"10485760",
"--max-redirs",
"0",
"--request",
method,
];
if (method === "PROPFIND") args.push("--header", "Depth: 1");
args.push(url);
const { stdout } = await run("curl", args, {
encoding: "buffer",
maxBuffer: 11 * 1024 * 1024,
timeout: 28000,
});
return stdout;
}
export function decodeListing(xml, root = SHARE) {
const entries = arr(parser.parse(xml).multistatus?.response);
return entries
.map((entry) => {
const props =
arr(entry.propstat).find((p) => p.status?.includes("200"))?.prop || {};
return {
path: entry.href,
folder: props.resourcetype?.collection !== undefined,
modified: props.getlastmodified || null,
size: Number(props.getcontentlength || 0),
};
})
.filter((e) => typeof e.path === "string" && e.path.startsWith(root));
}
const nameOf = (path) =>
decodeURIComponent(path).replace(/\/$/, "").split("/").at(-1);
export const isWeeksTable = (name) =>
/\.docx$/i.test(name) &&
/бел/i.test(name) &&
/зел[её]н/i.test(name) &&
/недел/i.test(name);
export const isBellSchedule = (name) =>
/\.docx$/i.test(name) && /звонк/i.test(name);
const isReference = (name) => isWeeksTable(name) || isBellSchedule(name);
export function fileMetadata(entry) {
const pieces = decodeURIComponent(entry.path).slice(SHARE.length).split("/");
const year = YEAR_FOLDER.test(pieces[0]) ? pieces.shift() : "";
const name = pieces.at(-1);
return {
...entry,
id: createHash("sha256").update(entry.path).digest("hex").slice(0, 24),
name,
year: year.replace("-", "–"),
faculty: pieces[0],
form: pieces[1] || "",
course: pieces.find((s) => /^\d курс$/.test(s)) || "",
section: pieces.slice(2, -1).join(" / "),
url: ORIGIN + entry.path,
};
}
/**
* The folder of the current academic year (from 1 September), else the newest
* earlier one, else the oldest later one. Folders look like "2026-2027".
*/
export function pickYearFolder(names, today = todayIso()) {
const target = academicStartYear(today);
const years = names
.map((name) => ({ name, m: name.match(YEAR_FOLDER) }))
.filter(({ m }) => m && Number(m[2]) === Number(m[1]) + 1)
.map(({ name, m }) => ({ name, start: Number(m[1]) }))
.sort((a, b) => b.start - a.start);
const expected = `${target}-${target + 1}`;
const pick =
years.find((y) => y.start === target) ||
years.find((y) => y.start < target) ||
years.at(-1);
return pick
? { name: pick.name, label: pick.name.replace("-", "–"), current: pick.start === target, expected }
: null;
}
/** Reference documents: newest first, the year folder before the share root. */
export function pickReferences(rootEntries, yearEntries = []) {
const files = (entries, scope) =>
entries
.filter((e) => !e.folder)
.map((e) => ({ ...e, name: nameOf(e.path), scope }));
const all = [...files(yearEntries, "year"), ...files(rootEntries, "root")];
const order = (a, b) =>
(a.scope === b.scope ? 0 : a.scope === "year" ? -1 : 1) ||
new Date(b.modified || 0) - new Date(a.modified || 0);
return {
weeks: all.filter((f) => isWeeksTable(f.name)).sort(order),
bells: all.filter((f) => isBellSchedule(f.name)).sort(order)[0] || null,
};
}
/** Lists the share root and the chosen year folder; cached for 15 minutes. */
async function discover(refresh = false) {
if (!refresh && sources && Date.now() - sources.checkedAt < DISCOVERY_TTL)
return sources;
if (!sourcesPending)
sourcesPending = (async () => {
const root = decodeListing(
(await request(SHARE, "PROPFIND")).toString(),
).filter((e) => e.path.toLowerCase() !== SHARE.toLowerCase());
const year = pickYearFolder(
root.filter((e) => e.folder).map((e) => nameOf(e.path)),
);
if (!year)
throw new Error("В каталоге университета нет папки учебного года.");
const yearRoot = SHARE + encodeURIComponent(year.name) + "/";
const yearEntries = decodeListing(
(await request(yearRoot, "PROPFIND")).toString(),
).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase());
return (sources = {
checkedAt: Date.now(),
year,
yearRoot: root.find((e) => nameOf(e.path) === year.name)?.path || yearRoot,
references: pickReferences(root, yearEntries),
});
})().finally(() => {
sourcesPending = null;
});
return sourcesPending;
}
async function crawl() {
const source = await discover(true);
let queue = [source.yearRoot],
files = [],
count = 0;
while (queue.length) {
if (count + queue.length > 180) throw new Error("Каталог слишком большой.");
const batch = queue.splice(0, 5);
count += batch.length;
const results = await Promise.all(
batch.map(async (path) =>
decodeListing((await request(path, "PROPFIND")).toString()).filter(
(e) => e.path.toLowerCase() !== path.toLowerCase(),
),
),
);
for (const entries of results)
for (const entry of entries) {
if (entry.folder) queue.push(entry.path);
else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path))
files.push(fileMetadata(entry));
}
}
const result = {
// Week tables and bell schedules are reference documents, not timetables.
files: files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)),
otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length,
updatedAt: new Date().toISOString(),
stale: false,
year: source.year.label,
currentYear: source.year.current,
expectedYear: source.year.expected.replace("-", "–"),
};
await mkdir(DATA, { recursive: true });
await writeFile(DATA + "catalog.json", JSON.stringify(result));
status.catalogError = null;
return (catalogue = result);
}
async function readCatalogCache() {
if (catalogue) return;
try {
catalogue = JSON.parse(await readFile(DATA + "catalog.json", "utf8"));
} catch {
// An initial source snapshot can be used during development after an interrupted sync.
try {
const files = JSON.parse(
await readFile(DATA + "source-catalog.json", "utf8"),
);
catalogue = {
files: files
.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name))
.map(fileMetadata),
updatedAt: null,
stale: true,
year: files[0] ? fileMetadata(files[0]).year : "",
};
} catch {}
}
}
export async function getCatalog(refresh = false) {
await readCatalogCache();
const expected = (() => {
const y = academicStartYear(todayIso());
return `${y}–${y + 1}`;
})();
// A cached catalog of last year is refreshed as soon as the new year begins.
const fresh =
catalogue &&
Date.now() - new Date(catalogue.updatedAt).getTime() < CATALOG_TTL &&
(catalogue.year === expected || catalogue.currentYear === false);
if (!refresh && fresh) return catalogue;
if (!pending)
pending = crawl()
.catch((error) => {
status.catalogError = error.message;
if (catalogue)
return {
...catalogue,
stale: true,
error: "Источник недоступен. Показана сохранённая копия каталога.",
};
throw error;
})
.finally(() => {
pending = null;
});
return pending;
}
export async function documentFor(id) {
const catalog = await getCatalog();
const file = catalog.files.find((f) => f.id === id);
if (!file) {
const error = new Error("Документ не найден в каталоге. Обновите список.");
error.status = 404;
throw error;
}
return file;
}
/** Merges every week table found (newest wins on overlap) and splits it into semesters. */
export function mergeWeekTables(tables) {
const byStart = new Map();
for (const weeks of tables)
for (const week of weeks)
if (!byStart.has(week.start)) byStart.set(week.start, week);
return assignSemesters([...byStart.values()]);
}
async function loadCalendar() {
const { references } = await discover(true);
if (!references.weeks.length)
throw new Error("Таблица белых и зелёных недель не найдена в каталоге.");
const tables = [];
for (const file of references.weeks)
try {
tables.push(parseWeeksDocx(await request(file.path)));
} catch (error) {
// One damaged copy must not hide a readable newer or older one.
console.error(`${file.name}: ${error.message}`);
}
if (!tables.length) throw new Error("Таблица недель не распознана.");
let bells = [];
if (references.bells)
try {
bells = parseBellsDocx(await request(references.bells.path));
} catch (error) {
console.error(`${references.bells.name}: ${error.message}`);
}
const { weeks, semesters } = mergeWeekTables(tables);
const result = {
weeks,
semesters,
// Keep the last known bells if the bell schedule became unreadable.
bells: bells.length ? bells : calendar?.bells || [],
source: {
weeks: references.weeks[0].modified,
bells: references.bells?.modified || null,
weeksFiles: references.weeks.map((f) => f.name),
bellsFile: references.bells?.name || null,
},
updatedAt: new Date().toISOString(),
stale: false,
};
await mkdir(DATA, { recursive: true });
await writeFile(DATA + "calendar.json", JSON.stringify(result));
status.calendarError = null;
return (calendar = result);
}
async function readCalendarCache() {
if (calendar) return;
try {
calendar = JSON.parse(await readFile(DATA + "calendar.json", "utf8"));
// Caches written before semesters existed are upgraded in place.
if (!calendar.semesters) calendar = { ...calendar, ...assignSemesters(calendar.weeks) };
} catch {}
}
export async function getCalendar(refresh = false) {
await readCalendarCache();
if (
!refresh &&
calendar &&
Date.now() - new Date(calendar.updatedAt).getTime() < CALENDAR_TTL
)
return calendar;
if (!calendarPending)
calendarPending = loadCalendar()
.catch((error) => {
status.calendarError = error.message;
if (calendar) return { ...calendar, stale: true, error: error.message };
throw error;
})
.finally(() => {
calendarPending = null;
});
return calendarPending;
}
/** What the server currently uses, for /api/health. Never touches the network. */
export async function sourceStatus(today = todayIso()) {
await Promise.all([readCatalogCache(), readCalendarCache()]);
const warnings = [];
const expected = academicStartYear(today);
const expectedYear = `${expected}–${expected + 1}`;
if (catalogue && catalogue.year !== expectedYear)
warnings.push(
`Папка учебного года ${expectedYear} не найдена или каталог ещё не обновлён; используется ${catalogue.year || "неизвестный год"}.`,
);
if (status.catalogError)
warnings.push(`Каталог не обновился: ${status.catalogError}`);
if (!calendar) warnings.push("Таблица недель ещё не загружена.");
else if (weekIndexOf(calendar, today) < 0)
warnings.push(
"Сегодняшняя дата вне таблицы недель: приложение показывает все строки Word.",
);
if (status.calendarError)
warnings.push(`Таблица недель не обновилась: ${status.calendarError}`);
const age = (iso) =>
iso ? Math.round((Date.now() - new Date(iso).getTime()) / 60000) : null;
return {
today,
year: sources?.year || (catalogue ? { label: catalogue.year } : null),
catalog: catalogue
? {
year: catalogue.year,
files: catalogue.files.length,
updatedAt: catalogue.updatedAt,
ageMinutes: age(catalogue.updatedAt),
stale: Boolean(catalogue.stale || status.catalogError),
}
: null,
calendar: calendar
? {
weeksFiles: calendar.source.weeksFiles || [],
weeksModified: calendar.source.weeks,
bellsFile: calendar.source.bellsFile || null,
semesters: (calendar.semesters || []).map((s) => ({
id: s.id,
label: s.label,
start: s.start,
end: s.end,
weeks: calendar.weeks.filter((w) => w.semester === s.id).length,
})),
bells: calendar.bells.length,
updatedAt: calendar.updatedAt,
ageMinutes: age(calendar.updatedAt),
coversToday: weekIndexOf(calendar, today) >= 0,
stale: Boolean(calendar.stale || status.calendarError),
}
: null,
warnings,
};
}