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(); }); // Raised whenever an endpoint is added or changed, so a deployment is easy to verify: // 2 — /api/timetable for the Android client; 3 — its range=semester. const API_VERSION = 3; // 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, apiVersion: API_VERSION, 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, range: req.query.range === "semester" ? "semester" : undefined, today: todayIso(), }), ); } 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}`)); });