From abddc62f8ccb86632028a08459319c9818ff22d1 Mon Sep 17 00:00:00 2001 From: jzitnik-dev Date: Tue, 2 Jun 2026 11:18:49 +0200 Subject: [PATCH] feat: Announcement API --- .env.example | 5 +++++ docker-compose.yml | 4 ++++ scrape/api/annoucements.ts | 41 ++++++++++++++++++++++++++++++++++ scrape/parse/archived/v1_v2.ts | 10 +++++---- scrape/parse/v3.ts | 23 +++++++++++-------- 5 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 scrape/api/annoucements.ts diff --git a/.env.example b/.env.example index a23c49a..c689b83 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,10 @@ EMAIL=username@spsejecna.cz PASSWORD=mojesupertajneheslo SHAREPOINT_URL=https://spsejecnacz.sharepoint.com/:x:/s/nastenka/ESy19K245Y9BouR5ksciMvgBu3Pn_9EaT0fpP8R6MrkEmg +# API for announcing errors and other global stuff for the client +# This server is open-source too, tho I don't recommend hosting it yourself, +# since it is used for global announcements and if you host it yourself, you won't get the latest news. +ANNOUNCEMENT_API=https://announcement.jecnarozvrh.jzitnik.dev + # For the viewer API_URl=http://localhost:3000 diff --git a/docker-compose.yml b/docker-compose.yml index 97a6eaa..0c74ac9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,5 +9,9 @@ services: NODE_ENV: production EMAIL: username@spsejecna.cz PASSWORD: mojesupertajneheslo + # API for announcing errors and other global stuff for the client + # This server is open-source too, tho I don't recommend hosting it yourself, + # since it is used for global announcements and if you host it yourself, you won't get the latest news. + ANNOUNCEMENT_API: https://announcement.jecnarozvrh.jzitnik.dev volumes: - ./volume:/usr/src/app/volume diff --git a/scrape/api/annoucements.ts b/scrape/api/annoucements.ts new file mode 100644 index 0000000..714adf1 --- /dev/null +++ b/scrape/api/annoucements.ts @@ -0,0 +1,41 @@ +const API_BASE_URL = process.env.ANNOUNCEMENT_API || "http://localhost:3000"; + +export type Announcement = { + id: number; + text_content: string; + author: string; + flags: Flag[]; + start_date: string; + end_date: string; +}; + +export enum Flag { + SHOW_ALL_ENTRIES +} + +export type AnnouncementResponse = { + [date: string]: Announcement[]; +} + +export default async function getAnnouncements(dates: string[]): Promise { + if (dates.length === 0) { + return {}; + } + + const url = new URL(`/v1/announcements/${dates.join(",")}`, API_BASE_URL).toString(); + + try { + const response = await fetch(url); + const data = await response.json(); + + if (!response.ok) { + return {}; + } + + return data; + } catch (e) { + console.error("Some random ahh error:", e); + + return {}; + } +} diff --git a/scrape/parse/archived/v1_v2.ts b/scrape/parse/archived/v1_v2.ts index 4042ef5..4a56578 100644 --- a/scrape/parse/archived/v1_v2.ts +++ b/scrape/parse/archived/v1_v2.ts @@ -1,4 +1,4 @@ -import fs from "fs" +import fs from "fs/promises" const CLASSES: string[] = [ "A1a", "A1b", "A1c", "C1a", "C1b", "C1c", "A2", "C2a", "C2b", "C2c", "E2", "C3a", "C3b", "C3c", "E3" @@ -45,7 +45,7 @@ interface ScheduleData { status: { lastUpdated: string }; } -export default function generateArchivedV1_V2(): void { +export default async function generateArchivedV1_V2() { const dates = getCurrentWeekMondayToFriday(); const currentDate = new Date(); const lastUpdated = currentDate.getHours().toString().padStart(2, "0") + ":" + currentDate.getMinutes().toString().padStart(2, "0"); @@ -73,6 +73,8 @@ export default function generateArchivedV1_V2(): void { data.schedule.push(d); } - fs.writeFileSync("volume/db/v1.json", JSON.stringify(data, null, 2)); - fs.writeFileSync("volume/db/v2.json", JSON.stringify(data, null, 2)); + await Promise.all([ + fs.writeFile("volume/db/v1.json", JSON.stringify(data, null, 2)), + fs.writeFile("volume/db/v2.json", JSON.stringify(data, null, 2)), + ]); } diff --git a/scrape/parse/v3.ts b/scrape/parse/v3.ts index ca99a7d..912bb92 100644 --- a/scrape/parse/v3.ts +++ b/scrape/parse/v3.ts @@ -18,6 +18,7 @@ import parseTeachers from "../utils/parseTeachers.js" import ExcelJS, { Worksheet, Cell, Row, Workbook } from "exceljs" import JSZip from "jszip"; import { parseStringPromise } from "xml2js"; +import getAnnouncements, { Flag } from "../api/annoucements.js"; interface ThemeColors { [key: number]: string | null; @@ -123,11 +124,13 @@ export default async function parseV3(workbook: Workbook, downloadedFilePath: st const upcoming = getUpcomingSheets(workbook); const resolvedDays = groupSheetsByDate(upcoming); + const getDates = resolvedDays.map((d) => d.dateKey); + const annoucements = await getAnnouncements(getDates); const schedule: any = {}; for (const { dateKey, sheet } of resolvedDays) { - const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap, themeColors); + const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap, themeColors, annoucements[dateKey].map(a => a.flags).flat()); schedule[dateKey] = { info: { inWork }, @@ -140,6 +143,7 @@ export default async function parseV3(workbook: Workbook, downloadedFilePath: st const data = { status: { lastUpdated: formatNowTime() }, + annoucements, schedule, }; @@ -202,9 +206,9 @@ function groupSheetsByDate(items: ResolvedDay[]) { // ──────────────────────────────────────────────────────────── // -function extractDaySchedule(sheet: Worksheet, teacherMap: Record, themeColors: ThemeColors | null) { +function extractDaySchedule(sheet: Worksheet, teacherMap: Record, themeColors: ThemeColors | null, flags: Flag[]) { return { - changes: extractClassChanges(sheet, themeColors), + changes: extractClassChanges(sheet, themeColors, flags), absence: extractAbsence(sheet, teacherMap), inWork: isPripravaSheet(sheet.name.toLowerCase()), takesPlace: extractTakesPlace(sheet), @@ -226,7 +230,8 @@ function isPripravaSheet(name: string) { // ──────────────────────────────────────────────────────────── // -function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null) { +function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null, flags: Flag[]) { + const ignoreColors = flags.includes(Flag.SHOW_ALL_ENTRIES) const classRegex = /[AEC][0-4][a-c]?\s*\/.*/s; const prefixRegex = /[AEC][0-4][a-c]?/; @@ -248,20 +253,20 @@ function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null) classCells.forEach((address, index) => { const row = sheet.getRow(Number(sheet.getCell(address).row)); - changes[classes[index]] = buildLessonArray(row, address, themeColors); + changes[classes[index]] = buildLessonArray(row, address, themeColors, ignoreColors); }); return changes; } -function buildLessonArray(row: Row, ignoreAddress: string, themeColors: ThemeColors | null) { +function buildLessonArray(row: Row, ignoreAddress: string, themeColors: ThemeColors | null, ignoreColors: boolean) { const lessons: (Lesson | null)[] = []; row.eachCell((cell) => { if (cell.address === ignoreAddress) return; const colIndex = letterToNumber(cell.address.replace(/[0-9]/g, "")); - lessons[colIndex] = parseLessonCell(cell, themeColors); + lessons[colIndex] = parseLessonCell(cell, themeColors, ignoreColors); }); const normalized = Array.from(lessons, (x) => (x === undefined ? null : x)); @@ -270,13 +275,13 @@ function buildLessonArray(row: Row, ignoreAddress: string, themeColors: ThemeCol return normalized.slice(1, 11); } -function parseLessonCell(cell: Cell, themeColors: ThemeColors | null): Lesson | null { +function parseLessonCell(cell: Cell, themeColors: ThemeColors | null, ignoreColors: boolean): Lesson | null { try { const text = (cell.text || "").trim(); const cleanupRegex = /^úklid\s+(?:\d+\s+)?[A-Za-z]{2}$/; // @ts-ignore - if (!text || cleanupRegex.test(text) || !cell.fill?.fgColor) return null; + if (!text || cleanupRegex.test(text) || (!cell.fill?.fgColor && !ignoreColors)) return null; const backgroundColor = resolveCellColor(cell, themeColors); const foregroundColor = !backgroundColor ? undefined : (