From de7ac3a48d542381c1a221ad13865e55e39cbad5 Mon Sep 17 00:00:00 2001 From: jzitnik-dev Date: Tue, 10 Feb 2026 21:36:53 +0100 Subject: [PATCH] feat: Background color fetching --- package-lock.json | 35 ++++++++++++++- package.json | 4 +- scrape/parse/v3.js | 106 ++++++++++++++++++++++++++++++++++++++++----- server.js | 2 +- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3e5b5e..84b557f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,10 @@ "dotenv": "^17.2.3", "exceljs": "^4.4.0", "express": "^5.1.0", + "jszip": "^3.10.1", "node-cron": "^4.2.1", - "puppeteer": "^24.10.0" + "puppeteer": "^24.10.0", + "xml2js": "^0.6.2" } }, "node_modules/@babel/code-frame": { @@ -2634,6 +2636,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -3217,6 +3228,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 24faf8d..302de91 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "dotenv": "^17.2.3", "exceljs": "^4.4.0", "express": "^5.1.0", + "jszip": "^3.10.1", "node-cron": "^4.2.1", - "puppeteer": "^24.10.0" + "puppeteer": "^24.10.0", + "xml2js": "^0.6.2" } } diff --git a/scrape/parse/v3.js b/scrape/parse/v3.js index 10564c4..0426fa4 100644 --- a/scrape/parse/v3.js +++ b/scrape/parse/v3.js @@ -16,10 +16,94 @@ import fs from "fs"; import parseAbsence from "../utils/parseAbsence.js" import parseTeachers from "../utils/parseTeachers.js" import ExcelJS from "exceljs" +import JSZip from "jszip"; +import { parseStringPromise } from "xml2js"; + +/** + * Read theme colors from the workbook + */ +async function getThemeColors(filePath) { + const data = fs.readFileSync(filePath); + const zip = await JSZip.loadAsync(data); + + // list all files for debug + + const themeFile = zip.file("xl/theme/theme1.xml"); + if (!themeFile) { + return null; + } + + const themeXml = await themeFile.async("text"); + const theme = await parseStringPromise(themeXml); + const scheme = theme["a:theme"]?.["a:themeElements"]?.[0]?.["a:clrScheme"]?.[0]; + + if (!scheme) return null; + + function getColor(node) { + if (node["a:srgbClr"]) return node["a:srgbClr"][0].$.val; + if (node["a:sysClr"]) return node["a:sysClr"][0].$.lastClr; + return null; + } + + const colors = { + 0: getColor(scheme["a:dk1"]?.[0]), + 1: getColor(scheme["a:lt1"]?.[0]), + 2: getColor(scheme["a:dk2"]?.[0]), + 3: getColor(scheme["a:lt2"]?.[0]), + 4: getColor(scheme["a:accent1"]?.[0]), + 5: getColor(scheme["a:accent2"]?.[0]), + 6: getColor(scheme["a:accent3"]?.[0]), + 7: getColor(scheme["a:accent4"]?.[0]), + 8: getColor(scheme["a:accent5"]?.[0]), + 9: getColor(scheme["a:accent6"]?.[0]), + }; + + return colors; +} + +/** + * Apply Excel tint to a base hex color + */ +function applyTintToHex(hex, tint = 0) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + + const tintChannel = (c) => + tint > 0 ? Math.round(c + (255 - c) * tint) : Math.round(c * (1 + tint)); + + const nr = tintChannel(r); + const ng = tintChannel(g); + const nb = tintChannel(b); + + return [nr, ng, nb] + .map((v) => v.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); +} + +/** + * Resolve final hex for a cell fill + */ +function resolveCellColor(cell, themeColors) { + if (!cell.fill?.fgColor) return null; + + const fg = cell.fill.fgColor; + + if (fg.argb) return `#${fg.argb}`; + if (fg.theme !== undefined && themeColors) { + const base = themeColors[fg.theme]; + if (!base) return null; + return `#${applyTintToHex(base, fg.tint ?? 0)}`; + } + + return null; +} export default async function parseV3(downloadedFilePath) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(downloadedFilePath); + const themeColors = await getThemeColors(downloadedFilePath); const teacherMap = await parseTeachers(); @@ -29,7 +113,7 @@ export default async function parseV3(downloadedFilePath) { const schedule = {}; for (const { dateKey, sheet } of resolvedDays) { - const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap); + const { changes, absence, inWork, takesPlace, reservedRooms } = extractDaySchedule(sheet, teacherMap, themeColors); schedule[dateKey] = { info: { inWork }, @@ -104,9 +188,9 @@ function groupSheetsByDate(items) { // ──────────────────────────────────────────────────────────── // -function extractDaySchedule(sheet, teacherMap) { +function extractDaySchedule(sheet, teacherMap, themeColors) { return { - changes: extractClassChanges(sheet), + changes: extractClassChanges(sheet, themeColors), absence: extractAbsence(sheet, teacherMap), inWork: isPripravaSheet(sheet.name.toLowerCase()), takesPlace: extractTakesPlace(sheet), @@ -128,7 +212,7 @@ function isPripravaSheet(name) { // ──────────────────────────────────────────────────────────── // -function extractClassChanges(sheet) { +function extractClassChanges(sheet, themeColors) { const classRegex = /[AEC][0-4][a-c]?\s*\/.*/s; const prefixRegex = /[AEC][0-4][a-c]?/; @@ -150,20 +234,20 @@ function extractClassChanges(sheet) { classCells.forEach((address, index) => { const row = sheet.getRow(sheet.getCell(address).row); - changes[classes[index]] = buildLessonArray(row, address); + changes[classes[index]] = buildLessonArray(row, address, themeColors); }); return changes; } -function buildLessonArray(row, ignoreAddress) { +function buildLessonArray(row, ignoreAddress, themeColors) { const lessons = []; row.eachCell((cell) => { if (cell.address === ignoreAddress) return; const colIndex = letterToNumber(cell.address.replace(/[0-9]/g, "")); - lessons[colIndex] = parseLessonCell(cell); + lessons[colIndex] = parseLessonCell(cell, themeColors); }); const normalized = Array.from(lessons, (x) => (x === undefined ? null : x)); @@ -172,15 +256,15 @@ function buildLessonArray(row, ignoreAddress) { return normalized.slice(1, 11); } -function parseLessonCell(cell) { +function parseLessonCell(cell, themeColors) { try { const text = (cell.text || "").trim(); const cleanupRegex = /^úklid\s+(?:\d+\s+)?[A-Za-z]{2}$/; if (!text || cleanupRegex.test(text) || !cell.fill?.fgColor) return null; - const backgroundColor = cell.fill.fgColor.argb === undefined ? undefined : `#${cell.fill.fgColor.argb}`; - const foregroundColor = backgroundColor === undefined ? undefined : ( + const backgroundColor = resolveCellColor(cell, themeColors); + const foregroundColor = !backgroundColor ? undefined : ( cell.font?.color?.argb === undefined ? undefined : `#${cell.font.color.argb}` ); @@ -318,4 +402,4 @@ function formatNowTime() { ); } -//parseV3("db/current.xlsx") +parseV3("db/current.xlsx") diff --git a/server.js b/server.js index 8ab6be6..3bfa335 100644 --- a/server.js +++ b/server.js @@ -91,7 +91,7 @@ app.post("/report", async (req, res) => { if (!className || !location || !content) { return res.status(400).json({ error: "Missing required fields." }); } - if (!["TIMETABLE", "ABSENCES", "OTHER"].includes(location)) { + if (!["TIMETABLE", "ABSENCES", "ABSENCE", "TAKES_PLACE", "OTHER"].includes(location)) { return res.status(400).json({ error: "Invalid location value." }); }