import Prisma from "@prisma/client"; import { log, getLogPath } from "../logs.js"; const prisma = new Prisma.PrismaClient(); export class Parser { constructor(fileProvider, documentParser, interval) { this.fileProvider = fileProvider; this.documentParser = documentParser; // Schedule plan updates setInterval(() => this.updatePlan(), interval); // Do the first update instantly this.updatePlan(); } async updatePlan() { const startedAt = new Date(); try { // Request substitution plan files using the fileProvider const files = await this.fileProvider.getFiles(); const plans = []; // Parse them using the provided parser for (const file of files) { // Parse the substitution plan const parsed = this.documentParser(file); plans.push(parsed); } // Create a new parse event const parseEvent = await prisma.parseEvent.create({ data: { logFile: getLogPath(), originalData: "", duration: new Date() - startedAt, succeeded: true, }, }); // Group plans by date to prevent having // multiple plan files with the same date const dayPlans = []; for (const plan of plans) { const foundPlan = dayPlans.find((e) => e.date == plan.date); if (!foundPlan) { // Make sure to not insert duplicate substitutions within a file const changes = structuredClone(plan.changes); const cleanedChanges = []; for (const change of changes) { const changeExists = cleanedChanges.find( (e) => JSON.stringify(e) == JSON.stringify(change), ); if (!changeExists) { cleanedChanges.push(change); } // Use the new array of changes plan.changes = cleanedChanges; } dayPlans.push(plan); } else { for (const change of plan.changes) { // Make sure to not insert a substitution that already exists in the changes const changeExists = foundPlan.changes.find( (e) => JSON.stringify(e) == JSON.stringify(change), ); if (!changeExists) { foundPlan.changes.push(change); } } } } // Insert substitutions of all substitution plans for (const plan of dayPlans) { await this.insertSubstitutions(plan, parseEvent); } } catch (error) { // If something went wrong, create a failed // parse event with the error message await prisma.parseEvent.create({ data: { logFile: getLogPath(), originalData: error.toString(), duration: new Date() - startedAt, succeeded: false, }, }); // Log the error log("Parser / Main", "Parse event failed: " + error); } } async insertSubstitutions(parsedData, parseEvent) { const { date, changes } = parsedData; const classList = await prisma.class.findMany(); const knownSubstitutions = await prisma.substitution.findMany({ where: { date: new Date(new Date(date).setUTCHours(0, 0, 0, 0)), removed: false, }, }); // Loop through every change of the substitution plan for (const change of changes) { // Find all classes the substitution belongs to const classes = this.getSubstitutionClasses(classList, change.class); // If the substitution does not belong to any classes known // by the server, use the provied class string instead if (classes.length == 0) classes.push(change.class || "unknown"); // Workaround: no correct match possible for subsitutions of this // type beacuse they do not have a class and a subject attribute if (change.type == "Sondereins." && !change.subject) { change.subject = change.notes; } if (change.type == "Sondereins." && !change.teacher) { change.teacher = change.changedTeacher; change.changedTeacher = ""; } // Check if a substitution exists in the database that // it similar enough to the entry in the substitution // plan to be considered the same substitution // (Date, Type, Lesson, Classes and Subject need to be the same) const matchingSubstitutionId = knownSubstitutions.findIndex( (substitution) => { return ( substitution.date.getTime() == new Date(date).setUTCHours(0, 0, 0, 0) && substitution.rawType == change.type && substitution.lesson == change.lesson && classes.sort().join(",") == substitution.class.sort().join(",") && substitution.changedSubject == change.subject && substitution.teacher == (change.teacher || "") ); }, ); const matchingSubstitution = knownSubstitutions[matchingSubstitutionId]; if (!matchingSubstitution) { // If the substitution is new, create it in the database const newSubstitution = await prisma.substitution.create({ data: { class: classes, date: new Date(date), type: change.type == "Entfall" || change.type == "eigenverantwortliches Arbeiten" ? "cancellation" : "change", rawType: change.type, lesson: parseInt(change.lesson), teacher: change.teacher || "", changedTeacher: change.changedTeacher, changedRoom: change.room || undefined, changedSubject: change.subject, notes: change.notes, removed: false, }, }); // Also create a change entry for it const substitutionChange = await prisma.substitutionChange.create({ data: { substitutionId: newSubstitution.id, type: "addition", changes: { class: classes, type: change.type == "Entfall" ? "cancellation" : "change", rawType: change.type, lesson: parseInt(change.lesson), date: new Date(date), notes: change.notes, teacher: change.teacher || "", change: { teacher: change.changedTeacher, room: change.room || undefined, subject: change.subject, }, }, parseEventId: parseEvent.id, }, }); log( "Insert / DB", `Created new substitution: S:${newSubstitution.id} C:${substitutionChange.id}`, ); } else { // If the entry was updated, find the differences const differences = this.findDifferences(matchingSubstitution, change); if (Object.keys(differences).length > 0) { // If differences were found, update the entry in the database const prismaOptions = { where: { id: matchingSubstitution.id, }, data: {}, }; if (differences.teacher) prismaOptions.data.changedTeacher = change.changedTeacher; if (differences.room) prismaOptions.data.changedRoom = change.room; if (differences.notes) prismaOptions.data.notes = change.notes; await prisma.substitution.update(prismaOptions); // And create a change event for it const substitutionChange = await prisma.substitutionChange.create({ data: { substitutionId: matchingSubstitution.id, type: "change", changes: differences, parseEventId: parseEvent.id, }, }); log( "Insert / DB", `Found changed substitution: S:${matchingSubstitution.id} C:${substitutionChange.id}`, ); } // Remove the substitution from the array to later know the // entries that are not present in the substitution plan knownSubstitutions.splice(matchingSubstitutionId, 1); } } // Mark all entries as removed that were // not found in the substitution plan for (const remainingSubstitution of knownSubstitutions) { await prisma.substitution.update({ where: { id: remainingSubstitution.id, }, data: { removed: true, }, }); const substitutionChange = await prisma.substitutionChange.create({ data: { substitutionId: remainingSubstitution.id, type: "deletion", changes: { class: remainingSubstitution.class, type: remainingSubstitution.type, rawType: remainingSubstitution.rawType, lesson: remainingSubstitution.lesson, date: remainingSubstitution.date.getTime(), notes: remainingSubstitution.notes, teacher: remainingSubstitution.teacher, change: { teacher: remainingSubstitution.changedTeacher, room: remainingSubstitution.changedRoom, subject: remainingSubstitution.changedSubject, }, }, parseEventId: parseEvent.id, }, }); log( "Insert / DB", `Deleted removed substitution: S:${remainingSubstitution.id} C:${substitutionChange.id}`, ); } } getSubstitutionClasses(classList, classString) { const matchingClasses = classList.filter((element) => { const regex = new RegExp(element.regex); return (classString || "").toLowerCase().match(regex); }); return matchingClasses.map((e) => e.name); } findDifferences(currentSubstitution, newChange) { const differences = {}; if (newChange.changedTeacher != currentSubstitution.changedTeacher) { differences.teacher = { before: currentSubstitution.changedTeacher, after: newChange.changedTeacher, }; } if (newChange.room != currentSubstitution.changedRoom) { differences.room = { before: currentSubstitution.changedRoom, after: newChange.room, }; } if (newChange.notes != currentSubstitution.notes) { differences.notes = { before: currentSubstitution.notes, after: newChange.notes, }; } return differences; } }