UNPKG

aion-ics

Version:

Aion DSL language for managing ICalendar data

552 lines (486 loc) 18 kB
import { AbstractParseTreeVisitor } from "antlr4ts/tree/AbstractParseTreeVisitor"; import { AionVisitor } from "../../core/antlr/generated/AionVisitor"; import * as AionParser from "../../core/antlr/generated/AionParser"; import { IcsEvent, IcsCalendar, generateIcsCalendar, convertIcsCalendar, } from "@timurcravtov/ts-ics"; import { getProdId } from "./helpers/getProdId"; import { createIcsEvent } from "./helpers/createIcsStructures"; import { IOSystem } from "./helpers/io_system/ioSystem"; import { TimeValidation, TimeValidationNormal, } from "../helpers/time_validation"; import { IODictionarySystem } from "./helpers/io_system/ioDictionarySystem"; import { AionRuntimeLoggingMessage } from "../helpers/AionRuntimeLoggingMessage"; import { time } from "console"; export class Interpreter extends AbstractParseTreeVisitor<void> implements AionVisitor<void> { private ioSystem: IOSystem; private timeValidator: TimeValidation; private calendars: Map<string, IcsEvent[]> = new Map(); private variables: Map<string, any> = new Map(); private currentCalendar: string = "main"; public constructor(params: { ioSystem: IOSystem; timeValidator: TimeValidation; }) { const { ioSystem = new IODictionarySystem(new Map()), timeValidator = new TimeValidationNormal(), } = params; super(); this.ioSystem = ioSystem; this.timeValidator = timeValidator; } protected defaultResult(): void {} visitProgram(ctx: AionParser.ProgramContext): void { for (const stmt of ctx.statement()) { this.visit(stmt); } const events = this.calendars.get(this.currentCalendar) || []; const calendar: IcsCalendar = { prodId: getProdId(), version: "2.0", events, }; const icsString = generateIcsCalendar(calendar); // console.log(icsString); } visitStatement(ctx: AionParser.StatementContext): void { if (ctx.structured_event_stmt()) { this.visitStructured_event_stmt(ctx.structured_event_stmt()); } else if (ctx.default_declaration()) { this.visitDefault_declaration(ctx.default_declaration()); } else if (ctx.import_stmt()) { this.visitImport_stmt(ctx.import_stmt()); } else if (ctx.assignment_stmt()) { this.visitAssignment_stmt(ctx.assignment_stmt()); } else if (ctx.value_assignment_stmt()) { this.visitValue_assignment_stmt(ctx.value_assignment_stmt()); } else if (ctx.merge_stmt()) { this.visitMerge_stmt(ctx.merge_stmt()); } else if (ctx.include_stmt()) { this.visitInclude_stmt(ctx.include_stmt()); } else if (ctx.export_stmt()) { this.visitExport_stmt(ctx.export_stmt()); } else if (ctx.loop_stmt()) { this.visitLoop_stmt(ctx.loop_stmt()); } else if (ctx.conditional_stmt()) { this.visitConditional_stmt(ctx.conditional_stmt()); } } visitImport_stmt(ctx: AionParser.Import_stmtContext): void { const file = ctx.STRING().text.replace(/"/g, ""); const alias = ctx.IDENTIFIER().text; console.log(`(Import) Pretending to import file: ${file} as ${alias}`); } visitAssignment_stmt(ctx: AionParser.Assignment_stmtContext): void { const name = ctx.IDENTIFIER().text; const declaration = ctx.declaration(); if (declaration) { const event = this.visitDeclaration(declaration); this.variables.set(name, event); console.log(`(Assignment) Stored declaration in variable: ${name}`); } } visitValue_assignment_stmt( ctx: AionParser.Value_assignment_stmtContext ): void { const name = ctx.IDENTIFIER().text; const value = ctx.value_expr().text; this.variables.set(name, value); console.log( `(Value Assignment) Stored value in variable: ${name} = ${value}` ); } visitMerge_stmt(ctx: AionParser.Merge_stmtContext): void { const identifiers = ctx.identifier_list().IDENTIFIER(); const target = identifiers[identifiers.length - 1].text; const list: IcsEvent[] = []; for (let id of identifiers) { const events = this.calendars.get(id.text) || []; list.push(...events); } this.calendars.set(target, list); console.log(`(Merge) Merged into: ${target}`); } visitInclude_stmt(ctx: AionParser.Include_stmtContext): void { const identifiers = ctx.IDENTIFIER(); const from = identifiers[0]?.text || ""; const to = identifiers[1]?.text || ""; const sourceEvents = this.calendars.get(from) || []; const destEvents = this.calendars.get(to) || []; destEvents.push(...sourceEvents); this.calendars.set(to, destEvents); console.log(`(Include) Included ${from} into ${to}`); } visitExport_stmt(ctx: AionParser.Export_stmtContext): void { console.log(`(Export) Calendar exported`); } visitDefault_declaration(ctx: AionParser.Default_declarationContext): void { if (ctx.event_decl()) { const event = this.visitEvent_decl(ctx.event_decl()); // Append to calendar.ics let events: IcsEvent[] = []; try { const icsContent = this.ioSystem.importFile("calendar.ics"); if (icsContent) { const calendar = convertIcsCalendar(undefined, icsContent); events = calendar.events || []; } } catch (e) { // console.log(`(Event Declaration) No existing calendar.ics found, creating new one`); } events.push(event); const updatedCalendar: IcsCalendar = { prodId: getProdId(), version: "2.0", events, }; const icsString = generateIcsCalendar(updatedCalendar); this.ioSystem.saveFile("calendar.ics", icsString); let event_created = new AionRuntimeLoggingMessage( `Appended event "${event.summary}" to calendar.ics`, "Event Declaration" ); console.log(event_created.toString()); } else if (ctx.task_decl()) { this.visitTask_decl(ctx.task_decl()); } else if (ctx.pomodoro_decl()) { this.visitPomodoro_decl(ctx.pomodoro_decl()); } } visitDeclaration(ctx: AionParser.DeclarationContext): IcsEvent | void { if (ctx.event_decl()) { return this.visitEvent_decl(ctx.event_decl()); } else if (ctx.task_decl()) { this.visitTask_decl(ctx.task_decl()); } else if (ctx.pomodoro_decl()) { this.visitPomodoro_decl(ctx.pomodoro_decl()); } } visitEvent_decl(ctx: AionParser.Event_declContext): IcsEvent { const name = ctx.STRING().text.replace(/"/g, ""); let start: Date; let end: Date; const timeSpec = ctx.event_time_spec(); const dateCtx = ctx.date(); if (timeSpec && dateCtx) { const times = timeSpec.time(); if (times.length >= 2) { console.log("here"); start = this.toDateTime(dateCtx, times[0]); end = this.toDateTime(dateCtx, times[1]); } else { start = this.toDateTime(dateCtx, times[0]); end = new Date(start.getTime() + 60 * 60 * 1000); } } else { start = new Date(); end = new Date(start.getTime() + 60 * 60 * 1000); } return createIcsEvent(name, start, end); } visitTask_decl(ctx: AionParser.Task_declContext): void { const name = ctx.STRING().text.replace(/"/g, ""); const dateCtx = ctx.date(); const timeCtx = ctx.task_time_spec().time()[0]; const [h, m] = timeCtx.text.split(":").map(Number); const [d, mo, y = new Date().getFullYear()] = dateCtx.text .split(".") .map(Number); const start = new Date(y, mo - 1, d, h, m); const end = new Date(start.getTime() + 30 * 60 * 1000); const event = createIcsEvent(name, start, end); const list = this.calendars.get(this.currentCalendar) || []; list.push(event); this.calendars.set(this.currentCalendar, list); } visitPomodoro_decl(ctx: AionParser.Pomodoro_declContext): void { const name = ctx.STRING().text.replace(/"/g, ""); const dateCtx = ctx.date(); const timeCtx = ctx.time(); const numberTokens = ctx.children?.filter( (c) => c.text && /^\d+$/.test(c.text) ); const repeatCount = numberTokens && numberTokens.length > 0 ? parseInt(numberTokens[0].text) : 1; const [h, m] = timeCtx.text.split(":").map(Number); const [d, mo, y = new Date().getFullYear()] = dateCtx.text .split(".") .map(Number); const base = new Date(y, mo - 1, d, h, m); let workDuration = 25; let breakDuration = 5; if (ctx.duration().length >= 1) { const raw = ctx.duration()[0].text; const matches = raw.match(/(\d+)([hm])/g); if (matches) { for (const m of matches) { const num = parseInt(m); const unit = m[m.length - 1]; if (unit === "h") workDuration += num * 60; else if (unit === "m") workDuration += num; } } } if (ctx.duration().length >= 2) { const raw = ctx.duration()[1].text; const matches = raw.match(/(\d+)([hm])/g); if (matches) { for (const m of matches) { const num = parseInt(m); const unit = m[m.length - 1]; if (unit === "h") breakDuration += num * 60; else if (unit === "m") breakDuration += num; } } } let current = new Date(base); for (let i = 0; i < repeatCount; i++) { const start = new Date(current); const end = new Date(current.getTime() + workDuration * 60 * 1000); current = new Date(end.getTime() + breakDuration * 60 * 1000); const event = createIcsEvent(`${name} #${i + 1}`, start, end); const list = this.calendars.get(this.currentCalendar) || []; list.push(event); this.calendars.set(this.currentCalendar, list); } } visitStructured_event_stmt( ctx: AionParser.Structured_event_stmtContext ): void { const fields = ctx.structured_event_field(); let name = "Untitled Event"; let startTime: Date = new Date(); let durationMinutes = 60; let location = "Unknown"; let category = "General"; const today = new Date(); const defaultDate = new Date( today.getFullYear(), today.getMonth(), today.getDate() ); for (const field of fields) { if (field.STRING()) { const label = field.getChild(0).text; const value = field.STRING().text.replace(/"/g, ""); if (label === "name") name = value; else if (label === "location") location = value; else if (label === "category") category = value; } else if (field.time()) { const label = field.getChild(0).text; if (label === "start") { const [h, m] = field.time().text.split(":").map(Number); startTime = new Date( defaultDate.getFullYear(), defaultDate.getMonth(), defaultDate.getDate(), h, m ); } } else if (field.duration()) { const label = field.getChild(0).text; if (label === "duration") { const matches = field.duration().text.match(/(\d+)([hm])/g) || []; durationMinutes = 0; for (const match of matches) { const num = parseInt(match); const unit = match[match.length - 1]; if (unit === "h") durationMinutes += num * 60; else if (unit === "m") durationMinutes += num; } } } } const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000); const event = createIcsEvent(name, startTime, endTime); event.location = location; event.categories = [category]; const list = this.calendars.get(this.currentCalendar) || []; list.push(event); this.calendars.set(this.currentCalendar, list); } visitLoop_stmt(ctx: AionParser.Loop_stmtContext): void { const startDate = this.resolveLoopStart(ctx.loop_start()); const endDate = this.resolveLoopEnd(ctx.loop_end(), startDate); const stepSize = ctx.NUMBER() ? parseInt(ctx.NUMBER().text) : 1; const unit = ctx.loop_unit().text.toLowerCase(); let currentDate = new Date(startDate); while (currentDate <= endDate) { (this as any).currentLoopDate = currentDate; for (const stmt of ctx.statement()) { this.visit(stmt); } if (unit.includes("day")) { currentDate.setDate(currentDate.getDate() + stepSize); } else if (unit.includes("week")) { currentDate.setDate(currentDate.getDate() + stepSize * 7); } else if (unit.includes("month")) { currentDate.setMonth(currentDate.getMonth() + stepSize); } else { throw new Error(`Unknown loop unit: ${unit}`); } } delete (this as any).currentLoopDate; } private resolveLoopStart(ctx: AionParser.Loop_startContext): Date { if (ctx.date()) { const dateText = ctx.date().text.trim().toLowerCase(); if (dateText === "today") { return new Date(); } return this.parseDate(dateText); } if (ctx.IDENTIFIER()) { const variable = ctx.IDENTIFIER().text; const value = this.variables.get(variable); if (value instanceof Date) { return value; } throw new Error(`Unknown loop start variable: ${variable}`); } throw new Error("Invalid loop start"); } private resolveLoopEnd( ctx: AionParser.Loop_endContext, startDate: Date ): Date { if (ctx.date()) { return this.parseDate(ctx.date().text); } if (ctx.IDENTIFIER()) { const value = this.variables.get(ctx.IDENTIFIER().text); if (value instanceof Date) { return value; } throw new Error(`Unknown loop end variable: ${ctx.IDENTIFIER().text}`); } if (ctx.loop_start() && ctx.NUMBER()) { const baseDate = this.resolveLoopStart(ctx.loop_start()); const offsetDays = parseInt(ctx.NUMBER().text); const result = new Date(baseDate); result.setDate(result.getDate() + offsetDays); return result; } throw new Error("Invalid loop end"); } private parseDate(text: string): Date { const parts = text.split(".").map(Number); const [d, m, y = new Date().getFullYear()] = parts; return new Date(y, m - 1, d); } visitConditional_stmt(ctx: AionParser.Conditional_stmtContext): void { let conditionMet = false; if (this.evaluateCondition(ctx.condition(0))) { conditionMet = true; this.visitBlock(ctx.statement(0)); } else { for (let i = 1; i < ctx.condition().length; i++) { if (this.evaluateCondition(ctx.condition(i))) { conditionMet = true; this.visitBlock(ctx.statement(i)); break; } } if (!conditionMet && ctx.statement(ctx.condition().length)) { this.visitBlock(ctx.statement(ctx.condition().length)); } } } private evaluateCondition( conditionCtx: AionParser.ConditionContext ): boolean { if (conditionCtx.IDENTIFIER()) { const variable = conditionCtx.IDENTIFIER().text; const value = this.variables.get(variable); const comparisonValue = this.evaluateComparisonValue( conditionCtx.value() ); switch (conditionCtx.comparison_op().text) { case "==": return value === comparisonValue; case "!=": return value !== comparisonValue; case "<": return value < comparisonValue; case "<=": return value <= comparisonValue; case ">": return value > comparisonValue; case ">=": return value >= comparisonValue; default: return false; } } return false; } private evaluateComparisonValue(valueCtx: AionParser.ValueContext): any { if (valueCtx.NUMBER()) { return parseInt(valueCtx.NUMBER().text); } else if (valueCtx.STRING()) { return valueCtx.STRING().text.replace(/"/g, ""); } return null; } private visitBlock( statements: AionParser.StatementContext | AionParser.StatementContext[] ): void { if (!Array.isArray(statements)) { statements = [statements]; } for (const stmt of statements) { this.visit(stmt); } } private toDateTime( dateCtx: AionParser.DateContext, timeCtx: AionParser.TimeContext ): Date { try { // Delegate date parsing to timeValidator const dateText = dateCtx.text.trim(); const parsedDate = this.timeValidator.validateDate(dateText); if (!parsedDate) { throw new Error( `Invalid date format: ${dateText}, expected dd.mm.yyyy` ); } // Delegate time parsing to timeValidator const timeText = timeCtx.text.trim(); const time = this.timeValidator.validateTime(timeText); if (!time) { throw new Error( `Invalid time format: ${timeText}, expected HH:mm or HH:mm AM/PM` ); } // Combine date and time const result = new Date( parsedDate.getFullYear(), parsedDate.getMonth(), parsedDate.getDate(), time.hours, time.minutes ); // Validate the resulting date if (isNaN(result.getTime())) { throw new Error(`Invalid date/time: ${dateText} ${timeText}`); } return result; } catch (error) { console.error(`Error parsing date/time: ${error.message}`); return new Date(); // Fallback to current date/time (May 23, 2025, 10:13 AM EEST) } } }