- Implemented a new Next Lesson widget to display the upcoming lesson details on the home screen. - Updated Today widget to dynamically adjust the number of lessons displayed based on available space. - Enhanced the Settings screen to allow users to manage hidden lessons more effectively. - Introduced harmonized color palettes for better visual consistency across themes. - Updated the timetable API to support a new range parameter for semester-based queries. - Added tests to ensure the new semester range functionality works as expected. - Refactored widget styling for improved appearance on devices with different Android versions.
218 lines
7.2 KiB
JavaScript
218 lines
7.2 KiB
JavaScript
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}`));
|
||
});
|