aion-ics
Version:
Aion DSL language for managing ICalendar data
552 lines (486 loc) • 18 kB
text/typescript
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)
}
}
}