diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c245c1d..07ad9c9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -10,6 +10,7 @@ - [Working with pages](#working-with-pages) - [Working with media](#working-with-media) - [Using macros](#using-macros) +- [Using scripting (early access)](#using-scripting-early-access) - [Organizing with tags](#organizing-with-tags) - [Importing from WordPress (WXR)](#importing-from-wordpress-wxr) - [Using Git (Source Control)](#using-git-source-control) @@ -196,6 +197,24 @@ Use macros when you need reusable rich blocks (for example embedded videos, medi --- +## Using scripting (early access) + +The scripting feature is an incremental capability and should currently be treated as early access. Scripts are stored as Python files in the project filesystem, while script metadata is tracked in the project database and embedded in the file metadata docstring block. This keeps scripts portable and inspectable while still allowing reliable indexing in the app. + +Each script exposes an **Entrypoint** selector. bDS always provides a synthetic `main` entrypoint. Selecting `main` runs the full script body as before. In addition, bDS inspects your script to list top-level Python function names, which can be selected as entrypoints for upcoming execution modes and integrations. + +At this stage, scripting is intended for controlled project workflows where scripts interact with application-provided tools. Keep scripts versioned through your normal Git workflow, review changes carefully, and prefer small, explicit scripts over monolithic utility files. + +### Key takeaways + +- Scripting is available and intentionally evolving in small steps. +- `main` is always available and preserves whole-script execution behavior. +- Script files and metadata remain filesystem-friendly and Git-reviewable. + +[↑ Back to In this article](#in-this-article) + +--- + ## Organizing with tags Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading. diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts index 0be90ab..ed9b379 100644 --- a/src/main/engine/ScriptEngine.ts +++ b/src/main/engine/ScriptEngine.ts @@ -63,9 +63,6 @@ export class ScriptEngine extends EventEmitter { 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, @@ -80,6 +77,9 @@ export class ScriptEngine extends EventEmitter { 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); @@ -108,20 +108,39 @@ export class ScriptEngine extends EventEmitter { await fs.rename(existing.filePath, nextFilePath); } - if (typeof updates.content === 'string') { - await fs.writeFile(nextFilePath, updates.content, 'utf-8'); - } + 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: updates.title ?? existing.title, + title: nextTitle, slug: nextSlug, - kind: updates.kind ?? existing.kind, - entrypoint: updates.entrypoint ?? existing.entrypoint, - enabled: updates.enabled ?? existing.enabled, + kind: nextKind, + entrypoint: nextEntrypoint, + enabled: nextEnabled, filePath: nextFilePath, - version: existing.version + 1, + version: nextVersion, updatedAt: now, }) .where(and(eq(scripts.id, existing.id), eq(scripts.projectId, this.currentProjectId))); @@ -187,15 +206,7 @@ export class ScriptEngine extends EventEmitter { } private async toScriptData(row: Script): Promise { - 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; - } - } + const content = await this.readScriptBody(row.filePath); return { id: row.id, @@ -256,6 +267,57 @@ export class ScriptEngine extends EventEmitter { return `${baseSlug}_${suffix}`; } + + private serializeScriptFile(row: Pick, content: string): string { + const lines = [ + '"""', + '---', + `id: ${this.toYamlString(row.id)}`, + `projectId: ${this.toYamlString(row.projectId)}`, + `slug: ${this.toYamlString(row.slug)}`, + `title: ${this.toYamlString(row.title)}`, + `kind: ${this.toYamlString(row.kind)}`, + `entrypoint: ${this.toYamlString(row.entrypoint)}`, + `enabled: ${row.enabled ? 'true' : 'false'}`, + `version: ${row.version}`, + `createdAt: ${this.toYamlString(row.createdAt.toISOString())}`, + `updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`, + '---', + '"""', + content, + ]; + + return lines.join('\n'); + } + + private toYamlString(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + return `"${escaped}"`; + } + + private parseScriptBody(rawContent: string): string { + const frontmatterDocstringPattern = /^(?:"""|''')\r?\n---\r?\n[\s\S]*?\r?\n---\r?\n(?:"""|''')\r?\n?/; + if (!frontmatterDocstringPattern.test(rawContent)) { + return rawContent; + } + + return rawContent.replace(frontmatterDocstringPattern, ''); + } + + private async readScriptBody(filePath: string): Promise { + try { + const rawContent = await fs.readFile(filePath, 'utf-8'); + return this.parseScriptBody(rawContent); + } catch (error) { + const fsError = error as NodeJS.ErrnoException; + if (fsError.code !== 'ENOENT') { + throw error; + } + return ''; + } + } } let scriptEngineInstance: ScriptEngine | null = null; diff --git a/src/renderer/components/ScriptsView/ScriptsView.tsx b/src/renderer/components/ScriptsView/ScriptsView.tsx index dd2ec7f..6a85fcd 100644 --- a/src/renderer/components/ScriptsView/ScriptsView.tsx +++ b/src/renderer/components/ScriptsView/ScriptsView.tsx @@ -28,12 +28,26 @@ export const ScriptsView: React.FC = ({ scriptId }) => { const [slug, setSlug] = useState(''); const [kind, setKind] = useState('utility'); const [entrypoint, setEntrypoint] = useState('render'); + const [availableEntrypoints, setAvailableEntrypoints] = useState([]); const [enabled, setEnabled] = useState(true); const [scriptContent, setScriptContent] = useState(''); const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isSaving, setIsSaving] = useState(false); + const buildCacheKey = (scriptMeta: Pick, content: string): string => { + let hash = 0; + for (let index = 0; index < content.length; index += 1) { + hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0; + } + return `${scriptMeta.id}:${scriptMeta.version}:${Math.abs(hash).toString(36)}`; + }; + + const withMainEntrypoint = (entrypoints: string[]): string[] => { + const filtered = entrypoints.filter((name) => name !== 'main'); + return ['main', ...filtered]; + }; + const toFunctionSlug = (value: string) => { const normalized = value .toLowerCase() @@ -45,6 +59,33 @@ export const ScriptsView: React.FC = ({ scriptId }) => { useEffect(() => { let cancelled = false; + const refreshEntrypoints = async (content: string, scriptMeta: ScriptData) => { + try { + const runtimeManager = getPythonRuntimeManager(); + const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(content, { + cacheKey: buildCacheKey(scriptMeta, content), + }); + const available = withMainEntrypoint(discoveredEntrypoints); + + if (cancelled) { + return; + } + + setAvailableEntrypoints(available); + + const preferredEntrypoint = available.includes(scriptMeta.entrypoint) + ? scriptMeta.entrypoint + : 'main'; + setEntrypoint(preferredEntrypoint); + } catch (error) { + if (cancelled) { + return; + } + setAvailableEntrypoints(['main']); + setEntrypoint('main'); + } + }; + const loadScript = async () => { if (!scriptId) { setScript(null); @@ -52,6 +93,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setSlug(''); setKind('utility'); setEntrypoint('render'); + setAvailableEntrypoints(['main']); setEnabled(true); setScriptContent(''); setIsSlugManuallyEdited(false); @@ -65,6 +107,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setSlug(''); setKind('utility'); setEntrypoint('render'); + setAvailableEntrypoints(['main']); setEnabled(true); setScriptContent(''); setIsSlugManuallyEdited(false); @@ -80,6 +123,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setScriptContent(item.content || ''); const normalizedExisting = toFunctionSlug(item.slug || item.title || ''); setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || '')); + await refreshEntrypoints(item.content || '', item); }; void loadScript(); @@ -117,11 +161,24 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setIsSaving(true); try { + const runtimeManager = getPythonRuntimeManager(); + const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, { + cacheKey: buildCacheKey(script, scriptContent), + }); + const available = withMainEntrypoint(discoveredEntrypoints); + + const normalizedEntrypoint = available.includes(entrypoint) + ? entrypoint + : 'main'; + + setAvailableEntrypoints(available); + setEntrypoint(normalizedEntrypoint); + const updated = await window.electronAPI?.scripts.update(script.id, { title, slug, kind, - entrypoint, + entrypoint: normalizedEntrypoint, enabled, content: scriptContent, }); @@ -135,6 +192,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setSlug(toFunctionSlug(updated.slug || updated.title || '')); setKind(updated.kind || 'utility'); setEntrypoint(updated.entrypoint || 'render'); + setAvailableEntrypoints(available); setEnabled(updated.enabled ?? true); setScriptContent(updated.content || ''); const normalizedExisting = toFunctionSlug(updated.slug || updated.title || ''); @@ -193,7 +251,10 @@ export const ScriptsView: React.FC = ({ scriptId }) => { try { const runtimeManager = getPythonRuntimeManager(); - const result = await runtimeManager.execute(scriptContent); + const result = await runtimeManager.execute(scriptContent, { + cacheKey: buildCacheKey(script, scriptContent), + entrypoint, + }); const now = new Date().toISOString(); if (result.result.trim().length > 0) { @@ -307,13 +368,16 @@ export const ScriptsView: React.FC = ({ scriptId }) => {
- setEntrypoint(event.target.value)} disabled={!script} - /> + > + {availableEntrypoints.map((name) => ( + + ))} +