feat: phase 1 of python scripting
This commit is contained in:
@@ -151,6 +151,24 @@ export const importDefinitions = sqliteTable('import_definitions', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Scripts table - stores metadata for Python scripts persisted in scripts/*.py
|
||||
export const scripts = sqliteTable('scripts', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
kind: text('kind', { enum: ['macro', 'utility', 'transform'] }).notNull().default('utility'),
|
||||
entrypoint: text('entrypoint').notNull().default('render'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
version: integer('version').notNull().default(1),
|
||||
filePath: text('file_path').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
// Composite unique index: slug must be unique within each project
|
||||
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
|
||||
}));
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -174,3 +192,5 @@ export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||
export type NewChatMessage = typeof chatMessages.$inferInsert;
|
||||
export type ImportDefinition = typeof importDefinitions.$inferSelect;
|
||||
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
|
||||
export type Script = typeof scripts.$inferSelect;
|
||||
export type NewScript = typeof scripts.$inferInsert;
|
||||
|
||||
264
src/main/engine/ScriptEngine.ts
Normal file
264
src/main/engine/ScriptEngine.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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 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<ScriptData> {
|
||||
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);
|
||||
|
||||
await fs.mkdir(this.getScriptsDir(), { recursive: true });
|
||||
await fs.writeFile(filePath, input.content, 'utf-8');
|
||||
|
||||
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 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<ScriptData | null> {
|
||||
const existing = await this.getScriptRow(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allScripts = await this.getAllScriptRows();
|
||||
const desiredSlug = this.normalizeSlug(updates.slug || 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);
|
||||
}
|
||||
|
||||
if (typeof updates.content === 'string') {
|
||||
await fs.writeFile(nextFilePath, updates.content, 'utf-8');
|
||||
}
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.update(scripts)
|
||||
.set({
|
||||
title: updates.title ?? existing.title,
|
||||
slug: nextSlug,
|
||||
kind: updates.kind ?? existing.kind,
|
||||
entrypoint: updates.entrypoint ?? existing.entrypoint,
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
filePath: nextFilePath,
|
||||
version: existing.version + 1,
|
||||
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<boolean> {
|
||||
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<ScriptData | null> {
|
||||
const row = await this.getScriptRow(id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.toScriptData(row);
|
||||
}
|
||||
|
||||
async getAllScripts(): Promise<ScriptData[]> {
|
||||
const rows = await this.getAllScriptRows();
|
||||
return Promise.all(rows.map((item) => this.toScriptData(item)));
|
||||
}
|
||||
|
||||
private async getScriptRow(id: string): Promise<Script | null> {
|
||||
const rows = await this.getAllScriptRows();
|
||||
return rows.find((item) => item.id === id) || null;
|
||||
}
|
||||
|
||||
private async getAllScriptRows(): Promise<Script[]> {
|
||||
return getDatabase().getLocal()
|
||||
.select()
|
||||
.from(scripts)
|
||||
.where(eq(scripts.projectId, this.currentProjectId))
|
||||
.orderBy(desc(scripts.updatedAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
private async toScriptData(row: Script): Promise<ScriptData> {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(row.filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
const fsError = error as NodeJS.ErrnoException;
|
||||
if (fsError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
entrypoint: row.entrypoint,
|
||||
enabled: row.enabled,
|
||||
version: row.version,
|
||||
filePath: row.filePath,
|
||||
content,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private getDataDir(): string {
|
||||
if (this.dataDir) {
|
||||
return this.dataDir;
|
||||
}
|
||||
|
||||
return path.join(app.getPath('userData'), 'projects', this.currentProjectId);
|
||||
}
|
||||
|
||||
private getScriptsDir(): string {
|
||||
return path.join(this.getDataDir(), 'scripts');
|
||||
}
|
||||
|
||||
private getScriptFilePath(slug: string): string {
|
||||
return path.join(this.getScriptsDir(), `${slug}.py`);
|
||||
}
|
||||
|
||||
private normalizeSlug(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return normalized || 'script';
|
||||
}
|
||||
|
||||
private ensureUniqueSlug(slug: string, rows: Script[], excludeId?: string): string {
|
||||
const baseSlug = slug;
|
||||
const taken = new Set(
|
||||
rows
|
||||
.filter((item) => item.id !== excludeId)
|
||||
.map((item) => item.slug)
|
||||
);
|
||||
|
||||
if (!taken.has(baseSlug)) {
|
||||
return baseSlug;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (taken.has(`${baseSlug}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
let scriptEngineInstance: ScriptEngine | null = null;
|
||||
|
||||
export function getScriptEngine(): ScriptEngine {
|
||||
if (!scriptEngineInstance) {
|
||||
scriptEngineInstance = new ScriptEngine();
|
||||
}
|
||||
return scriptEngineInstance;
|
||||
}
|
||||
@@ -100,3 +100,11 @@ export {
|
||||
type MenuDocument,
|
||||
type MenuItemKind,
|
||||
} from './MenuEngine';
|
||||
export {
|
||||
ScriptEngine,
|
||||
getScriptEngine,
|
||||
type ScriptData,
|
||||
type ScriptKind,
|
||||
type CreateScriptInput,
|
||||
type UpdateScriptInput,
|
||||
} from './ScriptEngine';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getMetaEngine } from '../engine/MetaEngine';
|
||||
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
|
||||
import { getTagEngine } from '../engine/TagEngine';
|
||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
|
||||
import { getGitEngine } from '../engine/GitEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
@@ -284,11 +285,13 @@ export function registerIpcHandlers(): void {
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
const scriptEngine = getScriptEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
scriptEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -322,11 +325,13 @@ export function registerIpcHandlers(): void {
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
const scriptEngine = getScriptEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
scriptEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
|
||||
@@ -723,6 +728,33 @@ export function registerIpcHandlers(): void {
|
||||
return engine.regenerateMissingThumbnails();
|
||||
});
|
||||
|
||||
// ============ Script Handlers ============
|
||||
|
||||
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.createScript(data);
|
||||
});
|
||||
|
||||
safeHandle('scripts:update', async (_, id: string, data: UpdateScriptInput) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.updateScript(id, data);
|
||||
});
|
||||
|
||||
safeHandle('scripts:delete', async (_, id: string) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.deleteScript(id);
|
||||
});
|
||||
|
||||
safeHandle('scripts:get', async (_, id: string) => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.getScript(id);
|
||||
});
|
||||
|
||||
safeHandle('scripts:getAll', async () => {
|
||||
const engine = getScriptEngine();
|
||||
return engine.getAllScripts();
|
||||
});
|
||||
|
||||
// ============ Task Handlers ============
|
||||
|
||||
safeHandle('tasks:getAll', async () => {
|
||||
|
||||
@@ -101,6 +101,15 @@ export const electronAPI: ElectronAPI = {
|
||||
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
||||
},
|
||||
|
||||
// Scripts
|
||||
scripts: {
|
||||
create: (data: { title: string; kind: import('./shared/electronApi').ScriptKind; content: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:create', data),
|
||||
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').ScriptKind; content?: string; slug?: string; entrypoint?: string; enabled?: boolean }) => ipcRenderer.invoke('scripts:update', id, data),
|
||||
delete: (id: string) => ipcRenderer.invoke('scripts:delete', id),
|
||||
get: (id: string) => ipcRenderer.invoke('scripts:get', id),
|
||||
getAll: () => ipcRenderer.invoke('scripts:getAll'),
|
||||
},
|
||||
|
||||
// Post-Media Links
|
||||
postMedia: {
|
||||
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
|
||||
|
||||
@@ -133,6 +133,23 @@ export interface MediaSearchResult {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
name: string;
|
||||
@@ -528,6 +545,27 @@ export interface ElectronAPI {
|
||||
getTags: () => Promise<string[]>;
|
||||
getTagsWithCounts: () => Promise<TagCount[]>;
|
||||
};
|
||||
scripts: {
|
||||
create: (data: {
|
||||
title: string;
|
||||
kind: ScriptKind;
|
||||
content: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}) => Promise<ScriptData>;
|
||||
update: (id: string, data: {
|
||||
title?: string;
|
||||
kind?: ScriptKind;
|
||||
content?: string;
|
||||
slug?: string;
|
||||
entrypoint?: string;
|
||||
enabled?: boolean;
|
||||
}) => Promise<ScriptData | null>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<ScriptData | null>;
|
||||
getAll: () => Promise<ScriptData[]>;
|
||||
};
|
||||
postMedia: {
|
||||
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
|
||||
unlink: (postId: string, mediaId: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user