import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import { app } from 'electron'; import { and, desc, eq } from 'drizzle-orm'; import { getDatabase } from '../database'; import { scripts, type NewScript, type Script } from '../database/schema'; export type ScriptKind = 'macro' | 'utility' | 'transform'; export interface ScriptData { id: string; projectId: string; slug: string; title: string; kind: ScriptKind; entrypoint: string; enabled: boolean; version: number; filePath: string; content: string; createdAt: Date; updatedAt: Date; } export interface CreateScriptInput { title: string; kind: ScriptKind; content: string; slug?: string; entrypoint?: string; enabled?: boolean; } export interface UpdateScriptInput { title?: string; kind?: ScriptKind; content?: string; slug?: string; entrypoint?: string; enabled?: boolean; } export type GitScriptFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed'; export interface GitScriptFileChange { status: GitScriptFileChangeStatus; path: string; previousPath?: string; } export interface ScriptReconcileResult { created: number; updated: number; deleted: number; processedFiles: number; } interface ParsedScriptFile { metadata: { id?: string; projectId?: string; slug?: string; title?: string; kind?: string; entrypoint?: string; enabled?: boolean; version?: number; createdAt?: string; updatedAt?: string; }; body: string; } export class ScriptEngine extends EventEmitter { private currentProjectId = 'default'; private dataDir: string | null = null; setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; this.dataDir = dataDir || null; } getProjectContext(): string { return this.currentProjectId; } async createScript(input: CreateScriptInput): Promise { const now = new Date(); const allScripts = await this.getAllScriptRows(); const desiredSlug = this.normalizeSlug(input.slug || input.title || 'script'); const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allScripts); const scriptId = uuidv4(); const filePath = this.getScriptFilePath(uniqueSlug); const row: NewScript = { id: scriptId, projectId: this.currentProjectId, slug: uniqueSlug, title: input.title, kind: input.kind, entrypoint: input.entrypoint || 'render', enabled: input.enabled ?? true, version: 1, filePath, createdAt: now, updatedAt: now, }; await fs.mkdir(this.getScriptsDir(), { recursive: true }); await fs.writeFile(filePath, this.serializeScriptFile(row as Script, input.content), 'utf-8'); await getDatabase().getLocal().insert(scripts).values(row); const created = await this.toScriptData(row as Script); this.emit('scriptCreated', created); return created; } async updateScript(id: string, updates: UpdateScriptInput): Promise { const existing = await this.getScriptRow(id); if (!existing) { return null; } const allScripts = await this.getAllScriptRows(); const desiredSlug = typeof updates.slug === 'string' ? this.normalizeSlug(updates.slug) : typeof updates.title === 'string' ? this.normalizeSlug(updates.title) : existing.slug; const nextSlug = this.ensureUniqueSlug(desiredSlug, allScripts, existing.id); const nextFilePath = this.getScriptFilePath(nextSlug); const now = new Date(); if (existing.filePath !== nextFilePath) { await fs.mkdir(this.getScriptsDir(), { recursive: true }); await fs.rename(existing.filePath, nextFilePath); } const nextTitle = updates.title ?? existing.title; const nextKind = updates.kind ?? existing.kind; const nextEntrypoint = updates.entrypoint ?? existing.entrypoint; const nextEnabled = updates.enabled ?? existing.enabled; const nextVersion = existing.version + 1; const nextContent = typeof updates.content === 'string' ? updates.content : await this.readScriptBody(nextFilePath); const nextRow = { ...existing, title: nextTitle, slug: nextSlug, kind: nextKind, entrypoint: nextEntrypoint, enabled: nextEnabled, filePath: nextFilePath, version: nextVersion, updatedAt: now, }; await fs.writeFile(nextFilePath, this.serializeScriptFile(nextRow, nextContent), 'utf-8'); await getDatabase().getLocal() .update(scripts) .set({ title: nextTitle, slug: nextSlug, kind: nextKind, entrypoint: nextEntrypoint, enabled: nextEnabled, filePath: nextFilePath, version: nextVersion, updatedAt: now, }) .where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId))); const updatedRow = await this.getScriptRow(existing.id); if (!updatedRow) { return null; } const updated = await this.toScriptData(updatedRow); this.emit('scriptUpdated', updated); return updated; } async deleteScript(id: string): Promise { const existing = await this.getScriptRow(id); if (!existing) { return false; } await getDatabase().getLocal() .delete(scripts) .where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId))); try { await fs.unlink(existing.filePath); } catch (error) { const fsError = error as NodeJS.ErrnoException; if (fsError.code !== 'ENOENT') { throw error; } } this.emit('scriptDeleted', id); return true; } async getScript(id: string): Promise { const row = await this.getScriptRow(id); if (!row) { return null; } return this.toScriptData(row); } async getAllScripts(): Promise { const rows = await this.getAllScriptRows(); return Promise.all(rows.map((item) => this.toScriptData(item))); } async rebuildDatabaseFromFiles(): Promise { const db = getDatabase().getLocal(); const scriptsDir = this.getScriptsDir(); await db.delete(scripts).where(eq(scripts.projectId, this.currentProjectId)); const pythonFiles = await this.scanScriptFiles(scriptsDir); if (pythonFiles.length === 0) { this.emit('scriptsRebuilt'); return; } const usedIds = new Set(); const insertedRows: Script[] = []; for (const filePath of pythonFiles) { const parsed = await this.readScriptFileWithMetadata(filePath); if (!parsed) { continue; } const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(filePath, '.py')); const slug = this.ensureUniqueSlug(desiredSlug, insertedRows); const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0 ? parsed.metadata.id.trim() : uuidv4(); const id = usedIds.has(desiredId) ? uuidv4() : desiredId; const now = new Date(); const row: NewScript = { id, projectId: this.currentProjectId, slug, title: this.normalizeTitle(parsed.metadata.title, slug), kind: this.normalizeKind(parsed.metadata.kind), entrypoint: this.normalizeEntrypoint(parsed.metadata.entrypoint), enabled: this.normalizeEnabled(parsed.metadata.enabled), version: this.normalizeVersion(parsed.metadata.version), filePath, createdAt: this.normalizeDate(parsed.metadata.createdAt, now), updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now), }; await db.insert(scripts).values(row); insertedRows.push(row as Script); usedIds.add(id); } this.emit('scriptsRebuilt'); } async reconcileScriptsFromGitChanges(projectPath: string, changes: GitScriptFileChange[]): Promise { const db = getDatabase().getLocal(); const normalizedProjectPath = path.resolve(projectPath); const relevantChanges = changes.filter((change) => { if (!this.isPythonScriptPath(change.path)) { return false; } if (change.status === 'renamed' && change.previousPath && !this.isPythonScriptPath(change.previousPath) && !this.isPythonScriptPath(change.path)) { return false; } return true; }); if (relevantChanges.length === 0) { return { created: 0, updated: 0, deleted: 0, processedFiles: 0 }; } const scriptRows = await this.getAllScriptRows(); const scriptsByPath = new Map(); for (const row of scriptRows) { scriptsByPath.set(this.normalizePathForCompare(row.filePath), row); } let created = 0; let updated = 0; let deleted = 0; let processedFiles = 0; for (const change of relevantChanges) { const absolutePath = this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.path)); const previousAbsolutePath = change.previousPath ? this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.previousPath)) : null; if (change.status === 'deleted') { const existing = scriptsByPath.get(absolutePath); if (!existing) { continue; } await db.delete(scripts).where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId))); scriptsByPath.delete(absolutePath); this.emit('scriptDeleted', existing.id); deleted += 1; processedFiles += 1; continue; } let existing = previousAbsolutePath ? (scriptsByPath.get(previousAbsolutePath) || scriptsByPath.get(absolutePath)) : scriptsByPath.get(absolutePath); const parsed = await this.readScriptFileWithMetadata(absolutePath); if (!parsed) { continue; } const allRows = await this.getAllScriptRows(); const parsedId = typeof parsed.metadata.id === 'string' ? parsed.metadata.id.trim() : ''; if (!existing && parsedId.length > 0) { const byId = allRows.find((row) => row.id === parsedId); if (byId) { existing = byId; } } const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(absolutePath, '.py')); const slug = this.ensureUniqueSlug(desiredSlug, allRows, existing?.id); if (existing) { const updateNow = new Date(); const nextRow = { title: this.normalizeTitle(parsed.metadata.title, slug, existing.title), slug, kind: this.normalizeKind(parsed.metadata.kind, existing.kind), entrypoint: this.normalizeEntrypoint(parsed.metadata.entrypoint, existing.entrypoint), enabled: this.normalizeEnabled(parsed.metadata.enabled, existing.enabled), version: this.normalizeVersion(parsed.metadata.version, existing.version), filePath: absolutePath, createdAt: this.normalizeDate(parsed.metadata.createdAt, existing.createdAt), updatedAt: this.normalizeDate(parsed.metadata.updatedAt, updateNow), }; await db.update(scripts) .set(nextRow) .where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId))); const updatedRow = await this.getScriptRow(existing.id); if (updatedRow) { const updatedScript = await this.toScriptData(updatedRow); this.emit('scriptUpdated', updatedScript); } if (previousAbsolutePath) { scriptsByPath.delete(previousAbsolutePath); } scriptsByPath.set(absolutePath, { ...existing, ...nextRow, }); updated += 1; processedFiles += 1; continue; } const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0 ? parsed.metadata.id.trim() : uuidv4(); const idExists = allRows.some((row) => row.id === desiredId); const rowId = idExists ? uuidv4() : desiredId; const now = new Date(); const newRow: NewScript = { id: rowId, projectId: this.currentProjectId, slug, title: this.normalizeTitle(parsed.metadata.title, slug), kind: this.normalizeKind(parsed.metadata.kind), entrypoint: this.normalizeEntrypoint(parsed.metadata.entrypoint), enabled: this.normalizeEnabled(parsed.metadata.enabled), version: this.normalizeVersion(parsed.metadata.version), filePath: absolutePath, createdAt: this.normalizeDate(parsed.metadata.createdAt, now), updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now), }; await db.insert(scripts).values(newRow); const createdRow = await this.getScriptRow(newRow.id); if (createdRow) { const createdScript = await this.toScriptData(createdRow); this.emit('scriptCreated', createdScript); } scriptsByPath.set(absolutePath, newRow as Script); created += 1; processedFiles += 1; } return { created, updated, deleted, processedFiles, }; } private async getScriptRow(id: string): Promise