Files
psu-schedule/server/index.js
2026-10-17 04:01:58 +03:00

211 lines
6.9 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 express from "express";
import { fileURLToPath } from "node:url";
import multer from "multer";
import { parseWord, PARSER_VERSION } from "./parser.js";
import { scheduleFor } from "./schedules.js";
import { timetable } from "./timetable.js";
import {
getIndex,
indexStatus,
search,
dayOf,
freeRooms,
examsOf,
findGroups,
directoryCalendar,
todayIso,
} from "./directory.js";
import {
getCatalog,
getCalendar,
documentFor,
request,
sourceStatus,
} from "./source.js";
const app = express();
app.disable("x-powered-by");
app.use("/api", (_req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
// Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
res.json({
ok: true,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),
});
});
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
const badRequest = (res, error) => res.status(400).json({ error });
// University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => {
try {
res.json(search(await getIndex(), String(req.query.q || "").slice(0, 60)));
} catch (e) {
next(e);
}
});
app.get("/api/directory/day", async (req, res, next) => {
try {
const date = req.query.date || todayIso();
const teacher = String(req.query.teacher || "").slice(0, 80),
room = String(req.query.room || "").slice(0, 20);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
if (!teacher && !room) return badRequest(res, "Укажите преподавателя или аудиторию.");
res.json(dayOf(await getIndex(), await directoryCalendar(), { teacher, room }, date));
} catch (e) {
next(e);
}
});
app.get("/api/directory/free", async (req, res, next) => {
try {
const date = req.query.date || todayIso(),
pair = Number(req.query.pair);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const calendar = await directoryCalendar();
const result = freeRooms(await getIndex(), calendar, date, pair);
if (!result) return badRequest(res, "Нет такой пары в расписании звонков.");
res.json(result);
} catch (e) {
next(e);
}
});
app.get("/api/groups", async (req, res, next) => {
try {
const text = (name) => String(req.query[name] || "").slice(0, 120);
res.json(
findGroups(await getIndex(), {
q: text("q"),
faculty: text("faculty"),
course: text("course"),
form: text("form"),
}),
);
} catch (e) {
next(e);
}
});
// A group's lessons by date with weeks applied: for native and other clients.
app.get("/api/timetable", async (req, res, next) => {
try {
const doc = String(req.query.doc || ""),
group = String(req.query.group || "").slice(0, 40),
from = req.query.from || todayIso();
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const file = await documentFor(doc);
res.json(
timetable({
file,
schedule: await scheduleFor(file),
calendar: await directoryCalendar(),
group,
from,
days: req.query.days,
}),
);
} catch (e) {
next(e);
}
});
app.get("/api/exams", async (req, res, next) => {
try {
const group = String(req.query.group || "").slice(0, 40);
if (!group) return badRequest(res, "Укажите группу.");
res.json({
group,
exams: examsOf(await getIndex(), group, String(req.query.faculty || "") || null),
});
} catch (e) {
next(e);
}
});
app.get("/api/catalog", async (req, res, next) => {
try {
res.json(await getCatalog(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/calendar", async (req, res, next) => {
try {
res.json(await getCalendar(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/schedule/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.json(await scheduleFor(file, { refresh: req.query.refresh === "1" }));
} catch (e) {
next(e);
}
});
app.get("/api/original/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.set("Content-Type", "application/octet-stream");
res.set(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
);
res.send(await request(file.path));
} catch (e) {
next(e);
}
});
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
});
app.post("/api/import", upload.single("file"), async (req, res, next) => {
try {
if (!req.file)
return res.status(400).json({ error: "Выберите файл Word." });
// Multer decodes multipart names as latin1 by default.
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
if (!/\.docx?$/i.test(name))
return res
.status(400)
.json({ error: "Поддерживаются файлы .docx и .doc." });
res.json(await parseWord(req.file.buffer, { name, uploaded: true }));
} catch (e) {
e.status = 422;
next(e);
}
});
const dist = fileURLToPath(new URL("../dist/", import.meta.url));
app.use(express.static(dist, { maxAge: 0 }));
app.get("/{*path}", (req, res, next) =>
req.path.startsWith("/api/")
? res.status(404).json({ error: "Адрес не найден." })
: res.sendFile(dist + "index.html", (err) => err && next(err)),
);
app.use((error, _req, res, _next) => {
console.error(error.message);
res
.status(error.code === "LIMIT_FILE_SIZE" ? 413 : error.status || 503)
.json({
error:
error.code === "LIMIT_FILE_SIZE"
? "Файл больше 10 МБ. Выберите документ меньшего размера."
: error.status === 422
? error.message
: error.status === 404
? error.message
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
});
});
const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`API и PWA: http://127.0.0.1:${port}`);
// Read the week table and parse the whole catalog in the background,
// so the first visit and the first search do not wait for the university.
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
});