1
0

Compare commits

..

13 Commits

Author SHA1 Message Date
jzitnik 1c6023beab chore: Added classes to announcement type
Remote Deploy / deploy (push) Successful in 1m26s
2026-06-02 17:13:32 +02:00
jzitnik f559d3a91b fix: No parse
Remote Deploy / deploy (push) Successful in 1m20s
2026-06-02 16:04:31 +02:00
jzitnik 1e971a0601 fix: Typo
Remote Deploy / deploy (push) Successful in 1m18s
2026-06-02 15:45:55 +02:00
jzitnik 32b31814e2 feat: Show always annoucements for this week
Remote Deploy / deploy (push) Successful in 1m35s
2026-06-02 12:29:00 +02:00
jzitnik 1f9543909a chore: Test
Remote Deploy / deploy (push) Successful in 1m16s
2026-06-02 12:12:35 +02:00
jzitnik b4118f7b25 chore: possible fix 2026-06-02 12:09:42 +02:00
jzitnik abddc62f8c feat: Announcement API
Remote Deploy / deploy (push) Successful in 2m38s
2026-06-02 11:18:49 +02:00
jzitnik 939633b675 chore: Archive v1 and v2
Remote Deploy / deploy (push) Successful in 1m31s
2026-05-06 17:23:38 +02:00
jzitnik 95766c62a0 docs: Fix urls 2026-04-15 17:45:50 +02:00
jzitnik 3094ec1501 chore: I hate my life 2026-04-15 17:07:56 +02:00
jzitnik ed5e493912 fix: Date can have multiple dots between numbers.
Somehow DON'T ASK ME WHY
2026-04-15 17:00:47 +02:00
jzitnik 38b494f066 docs: Remove double heading in contributing 2026-03-25 10:42:57 +01:00
jzitnik 448b565835 perf: Do not read worksheet twice 2026-03-25 10:41:24 +01:00
13 changed files with 203 additions and 36 deletions
+5
View File
@@ -2,5 +2,10 @@ EMAIL=username@spsejecna.cz
PASSWORD=mojesupertajneheslo PASSWORD=mojesupertajneheslo
SHAREPOINT_URL=https://spsejecnacz.sharepoint.com/:x:/s/nastenka/ESy19K245Y9BouR5ksciMvgBu3Pn_9EaT0fpP8R6MrkEmg 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 # For the viewer
API_URl=http://localhost:3000 API_URl=http://localhost:3000
+4
View File
@@ -9,5 +9,9 @@ services:
NODE_ENV: production NODE_ENV: production
EMAIL: username@spsejecna.cz EMAIL: username@spsejecna.cz
PASSWORD: mojesupertajneheslo 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: volumes:
- ./volume:/usr/src/app/volume - ./volume:/usr/src/app/volume
+42
View File
@@ -0,0 +1,42 @@
const API_BASE_URL = process.env.ANNOUNCEMENT_API || "http://localhost:3000";
export type Announcement = {
id: number;
text_content?: string;
author: string;
classes: 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<AnnouncementResponse> {
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 {};
}
}
+12 -3
View File
@@ -12,10 +12,19 @@
* GNU General Public License for more details. * GNU General Public License for more details.
*/ */
import parseV1V2 from "./parse/v1_v2.js"; import ExcelJS from "exceljs"
//import parseV1V2 from "./parse/v1_v2.js";
import parseV3 from "./parse/v3.js"; import parseV3 from "./parse/v3.js";
import generateArchivedV1_V2 from "./parse/archived/v1_v2.js";
export default async function parseThisShit(downloadedFilePath: string) { export default async function parseThisShit(downloadedFilePath: string) {
await parseV1V2(downloadedFilePath); const workbook = new ExcelJS.Workbook();
await parseV3(downloadedFilePath); await workbook.xlsx.readFile(downloadedFilePath);
//await parseV1V2(workbook);
await generateArchivedV1_V2();
await parseV3(workbook, downloadedFilePath);
} }
//parseThisShit("volume/db/current.xlsx")
+80
View File
@@ -0,0 +1,80 @@
import fs from "fs/promises"
const CLASSES: string[] = [
"A1a", "A1b", "A1c", "C1a", "C1b", "C1c", "A2", "C2a", "C2b", "C2c", "E2", "C3a", "C3b", "C3c", "E3"
];
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
const EXAMPLE_SCHEDULE: (string | null)[] = [
"Verze 1 API bude ukončena",
"Prosím aktualizujte si aplikaci.",
"Po ukončení Vám nebude fungovat",
"mimořádný rozvrh.",
null,
"Pokud jste vývojář aplikace,",
"podívejte se na dokumentaci na",
"jecnarozvrh.jzitnik.dev",
null,
null
];
function getCurrentWeekMondayToFriday(referenceDate: Date = new Date()): string[] {
const date = new Date(referenceDate);
const day = date.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(date);
monday.setDate(date.getDate() + diffToMonday);
const week: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
week.push(formatDate(d));
}
return week;
}
interface ScheduleData {
schedule: Record<string, (string | null)[]>[];
props: { date: string; priprava: boolean }[];
status: { lastUpdated: string };
}
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");
const data: ScheduleData = {
schedule: [],
props: [],
status: {
lastUpdated
}
};
for (const date of dates) {
data.props.push({
date,
priprava: false
});
const d: Record<string, (string | null)[]> = {};
for (const cls of CLASSES) {
d[cls] = EXAMPLE_SCHEDULE;
}
data.schedule.push(d);
}
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)),
]);
}
+2 -4
View File
@@ -12,7 +12,7 @@
* GNU General Public License for more details. * GNU General Public License for more details.
*/ */
import ExcelJS, { Worksheet } from "exceljs" import { Workbook, Worksheet } from "exceljs"
import fs from "fs" import fs from "fs"
import parseAbsence, { AbsenceResult } from "../utils/parseAbsence.js" import parseAbsence, { AbsenceResult } from "../utils/parseAbsence.js"
import parseTeachers from "../utils/parseTeachers.js" import parseTeachers from "../utils/parseTeachers.js"
@@ -27,9 +27,7 @@ interface ScheduleDay {
ABSENCE?: AbsenceResult[]; ABSENCE?: AbsenceResult[];
} }
export default async function parseV1V2(downloadedFilePath: string) { export default async function parseV1V2(workbook: Workbook) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(downloadedFilePath);
const teacherMap = await parseTeachers(); const teacherMap = await parseTeachers();
const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4}|\d{2})/i; const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4}|\d{2})/i;
+45 -18
View File
@@ -15,9 +15,10 @@
import fs from "fs"; import fs from "fs";
import parseAbsence from "../utils/parseAbsence.js" import parseAbsence from "../utils/parseAbsence.js"
import parseTeachers from "../utils/parseTeachers.js" import parseTeachers from "../utils/parseTeachers.js"
import ExcelJS, { Worksheet, Cell, Row } from "exceljs" import ExcelJS, { Worksheet, Cell, Row, Workbook } from "exceljs"
import JSZip from "jszip"; import JSZip from "jszip";
import { parseStringPromise } from "xml2js"; import { parseStringPromise } from "xml2js";
import getAnnouncements, { Flag } from "../api/announcements.js";
interface ThemeColors { interface ThemeColors {
[key: number]: string | null; [key: number]: string | null;
@@ -42,8 +43,6 @@ async function getThemeColors(filePath: string): Promise<ThemeColors | null> {
const data = fs.readFileSync(filePath); const data = fs.readFileSync(filePath);
const zip = await JSZip.loadAsync(data); const zip = await JSZip.loadAsync(data);
// list all files for debug
const themeFile = zip.file("xl/theme/theme1.xml"); const themeFile = zip.file("xl/theme/theme1.xml");
if (!themeFile) { if (!themeFile) {
return null; return null;
@@ -118,9 +117,7 @@ function resolveCellColor(cell: Cell, themeColors: ThemeColors | null) {
return null; return null;
} }
export default async function parseV3(downloadedFilePath: string) { export default async function parseV3(workbook: Workbook, downloadedFilePath: string) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(downloadedFilePath);
const themeColors = await getThemeColors(downloadedFilePath); const themeColors = await getThemeColors(downloadedFilePath);
const teacherMap = await parseTeachers(); const teacherMap = await parseTeachers();
@@ -128,10 +125,20 @@ export default async function parseV3(downloadedFilePath: string) {
const upcoming = getUpcomingSheets(workbook); const upcoming = getUpcomingSheets(workbook);
const resolvedDays = groupSheetsByDate(upcoming); const resolvedDays = groupSheetsByDate(upcoming);
const sheetDates = resolvedDays.map((d) => d.dateKey);
const announcementDates = getWeekDates().concat(sheetDates);
if (new Date().getDay() === 0) {
announcementDates.push(...getWeekDates(7));
}
const announcements = await getAnnouncements([...new Set(announcementDates)]);
const schedule: any = {}; const schedule: any = {};
for (const { dateKey, sheet } of resolvedDays) { for (const { dateKey, sheet } of resolvedDays) {
const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap, themeColors); const ann = announcements[dateKey];
const allFlags = ann.map(a => a.flags).flat();
const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap, themeColors, allFlags);
schedule[dateKey] = { schedule[dateKey] = {
info: { inWork }, info: { inWork },
@@ -144,6 +151,7 @@ export default async function parseV3(downloadedFilePath: string) {
const data = { const data = {
status: { lastUpdated: formatNowTime() }, status: { lastUpdated: formatNowTime() },
announcements,
schedule, schedule,
}; };
@@ -157,7 +165,7 @@ export default async function parseV3(downloadedFilePath: string) {
// //
function getUpcomingSheets(workbook: ExcelJS.Workbook): ResolvedDay[] { function getUpcomingSheets(workbook: ExcelJS.Workbook): ResolvedDay[] {
const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4}|\d{2})/i; const dateRegex = /^(pondělí|úterý|středa|čtvrtek|pátek|po|út|ut|st|čt|ct|pa|pá)\s+(\d{1,2})\.+\s*(\d{1,2})\.+\s*(\d{4}|\d{2})/i;
const today = new Date(); const today = new Date();
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
@@ -206,9 +214,9 @@ function groupSheetsByDate(items: ResolvedDay[]) {
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// //
function extractDaySchedule(sheet: Worksheet, teacherMap: Record<string, string>, themeColors: ThemeColors | null) { function extractDaySchedule(sheet: Worksheet, teacherMap: Record<string, string>, themeColors: ThemeColors | null, flags: Flag[]) {
return { return {
changes: extractClassChanges(sheet, themeColors), changes: extractClassChanges(sheet, themeColors, flags),
absence: extractAbsence(sheet, teacherMap), absence: extractAbsence(sheet, teacherMap),
inWork: isPripravaSheet(sheet.name.toLowerCase()), inWork: isPripravaSheet(sheet.name.toLowerCase()),
takesPlace: extractTakesPlace(sheet), takesPlace: extractTakesPlace(sheet),
@@ -230,7 +238,9 @@ function isPripravaSheet(name: string) {
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// //
function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null) { function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null, flags: Flag[]) {
console.log(flags)
const ignoreColors = flags.includes(Flag.SHOW_ALL_ENTRIES)
const classRegex = /[AEC][0-4][a-c]?\s*\/.*/s; const classRegex = /[AEC][0-4][a-c]?\s*\/.*/s;
const prefixRegex = /[AEC][0-4][a-c]?/; const prefixRegex = /[AEC][0-4][a-c]?/;
@@ -252,20 +262,20 @@ function extractClassChanges(sheet: Worksheet, themeColors: ThemeColors | null)
classCells.forEach((address, index) => { classCells.forEach((address, index) => {
const row = sheet.getRow(Number(sheet.getCell(address).row)); 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; 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)[] = []; const lessons: (Lesson | null)[] = [];
row.eachCell((cell) => { row.eachCell((cell) => {
if (cell.address === ignoreAddress) return; if (cell.address === ignoreAddress) return;
const colIndex = letterToNumber(cell.address.replace(/[0-9]/g, "")); 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)); const normalized = Array.from(lessons, (x) => (x === undefined ? null : x));
@@ -274,13 +284,13 @@ function buildLessonArray(row: Row, ignoreAddress: string, themeColors: ThemeCol
return normalized.slice(1, 11); 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 { try {
const text = (cell.text || "").trim(); const text = (cell.text || "").trim();
const cleanupRegex = /^úklid\s+(?:\d+\s+)?[A-Za-z]{2}$/; const cleanupRegex = /^úklid\s+(?:\d+\s+)?[A-Za-z]{2}$/;
// @ts-ignore // @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 backgroundColor = resolveCellColor(cell, themeColors);
const foregroundColor = !backgroundColor ? undefined : ( const foregroundColor = !backgroundColor ? undefined : (
@@ -419,6 +429,25 @@ function letterToNumber(letter: string) {
return letter.toLowerCase().charCodeAt(0) - 97; return letter.toLowerCase().charCodeAt(0) - 97;
} }
function getWeekDates(offset: number = 0): string[] {
const today = new Date();
const day = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() + (day === 0 ? -6 : 1 - day) + offset);
const dates: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
dates.push(formatDateKey(d));
}
return dates;
}
function formatDateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function formatNowTime() { function formatNowTime() {
const now = new Date(); const now = new Date();
return ( return (
@@ -427,5 +456,3 @@ function formatNowTime() {
now.getMinutes().toString().padStart(2, "0") now.getMinutes().toString().padStart(2, "0")
); );
} }
//parseV3("volume/db/current.xlsx")
+1 -1
View File
@@ -8,7 +8,7 @@ TocOpen: true
Ječná Rozvrh API má svoji Rust knihovnu pro komunikaci s API. Obsahuje mappings pro Kotlin. Pro další jazyky budou mappingy v budoucnu. Ječná Rozvrh API má svoji Rust knihovnu pro komunikaci s API. Obsahuje mappings pro Kotlin. Pro další jazyky budou mappingy v budoucnu.
[Zdrojový kód knihovny](https://gitea.local.jzitnik.dev/jzitnik/jecna-supl-client) [Zdrojový kód knihovny](https://gitea.jzitnik.dev/jzitnik/jecna-supl-client)
## Usage ## Usage
+5 -5
View File
@@ -6,6 +6,8 @@ tags: ["api", "docs"]
Vítejte v dokumentaci pro API systému Ječná Rozvrh. Toto API poskytuje programový přístup k rozvrhům, suplování a dalším informacím. Vítejte v dokumentaci pro API systému Ječná Rozvrh. Toto API poskytuje programový přístup k rozvrhům, suplování a dalším informacím.
OpenAPI spec a SwaggerUI je v přípravě!
## Oficiální knihovna ## Oficiální knihovna
[Oficiální knihovna pro komunikaci s Ječná Rozvrh API](../lib) [Oficiální knihovna pro komunikaci s Ječná Rozvrh API](../lib)
@@ -36,16 +38,14 @@ API je verzované, aby byla zajištěna zpětná kompatibilita. Zde je seznam do
Toto je aktuální a doporučená verze API. Obsahuje velké změny oproti V2 a obsahuje nové data. Toto je aktuální a doporučená verze API. Obsahuje velké změny oproti V2 a obsahuje nové data.
- ### [Verze 2 (v2)](../v2) - ### [~Verze 2 (v2)~](../v2)
**Status:** Stabilní **Status:** Archivováno (API již není v provozu)
**Endpoint:** `/versioned/v2` **Endpoint:** `/versioned/v2`
- ### [~Verze 1 (v1)~](../v1) - ### [~Verze 1 (v1)~](../v1)
**Status:** Deprecated **Status:** Archivováno (API již není v provozu)
**Endpoint:** `/versioned/v1` **Endpoint:** `/versioned/v1`
Verze 1 bude v budoucnu odstaněna. Migrujte na novější verze
--- ---
## Nezařazené Endpointy ## Nezařazené Endpointy
+2 -2
View File
@@ -5,8 +5,8 @@ tags: ["api", "docs", "v1"]
hiddenInHomelist: true hiddenInHomelist: true
--- ---
{{< admonition type="warning" title="Deprecated" >}} {{< admonition type="warning" title="Archoviváno" >}}
Tato verze je **deprecated**. Prosím nepoužívejte ji, bude brzy odstraněna. Tato verze je **archovována**. API již není v provozu.
{{< /admonition >}} {{< /admonition >}}
Tato stránka detailně popisuje **Verzi 1 (v1)** API Ječná Rozvrh. Tato stránka detailně popisuje **Verzi 1 (v1)** API Ječná Rozvrh.
+4
View File
@@ -5,6 +5,10 @@ tags: ["api", "docs", "v2"]
hiddenInHomelist: true hiddenInHomelist: true
--- ---
{{< admonition type="warning" title="Archoviváno" >}}
Tato verze je **archovována**. API již není v provozu.
{{< /admonition >}}
Tato stránka detailně popisuje **Verzi 2 (v2)** API Ječná Rozvrh. Tato stránka detailně popisuje **Verzi 2 (v2)** API Ječná Rozvrh.
## Endpoint: `GET /versioned/v2` ## Endpoint: `GET /versioned/v2`
-2
View File
@@ -5,8 +5,6 @@ tags: ["code", "contribution"]
hiddenInHomelist: true hiddenInHomelist: true
--- ---
# Contributing
Děkuji za váš zájem přispět do tohoto projektu. Děkuji za váš zájem přispět do tohoto projektu.
Příspěvky jsou vítány, ale **přijímám je pouze e-mailem ve formě `.patch` souborů**. Příspěvky jsou vítány, ale **přijímám je pouze e-mailem ve formě `.patch` souborů**.
+1 -1
View File
@@ -23,7 +23,7 @@ Použití Dockeru je nejjednodušší způsob, jak projekt spustit, protože aut
1. **Klonování repozitáře**: 1. **Klonování repozitáře**:
```bash ```bash
git clone https://gitea.local.jzitnik.dev/jzitnik/jecnarozvrh.git git clone https://gitea.jzitnik.dev/jzitnik/jecnarozvrh.git
cd jecnarozvrh cd jecnarozvrh
``` ```