import React, { useState, useEffect, FormEvent, useCallback } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Select } from '../ui/Select'; import cronstrue from 'cronstrue'; import * as yaml from 'yaml'; import { Buffer } from 'buffer'; import { Recipe } from '../../recipe'; type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'; interface FrequencyOption { value: FrequencyValue; label: string; } export interface NewSchedulePayload { id: string; recipe_source: string; cron: string; } interface CreateScheduleModalProps { isOpen: boolean; onClose: () => void; onSubmit: (payload: NewSchedulePayload) => Promise; isLoadingExternally: boolean; apiErrorExternally: string | null; } // Interface for clean extension in YAML interface CleanExtension { name: string; type: 'stdio' | 'sse' | 'builtin' | 'frontend'; cmd?: string; args?: string[]; uri?: string; display_name?: string; tools?: unknown[]; instructions?: string; env_keys?: string[]; timeout?: number; description?: string; bundled?: boolean; } // Interface for clean recipe in YAML interface CleanRecipe { title: string; description: string; instructions: string; prompt?: string; activities?: string[]; extensions?: CleanExtension[]; goosehints?: string; context?: string[]; profile?: string; author?: { contact?: string; metadata?: string; }; } const frequencies: FrequencyOption[] = [ { value: 'once', label: 'Once' }, { value: 'hourly', label: 'Hourly' }, { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' }, { value: 'monthly', label: 'Monthly' }, ]; const daysOfWeekOptions: { value: string; label: string }[] = [ { value: '1', label: 'Mon' }, { value: '2', label: 'Tue' }, { value: '3', label: 'Wed' }, { value: '4', label: 'Thu' }, { value: '5', label: 'Fri' }, { value: '6', label: 'Sat' }, { value: '0', label: 'Sun' }, ]; const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1'; const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1'; const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300'; const checkboxInputClassName = 'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2'; type SourceType = 'file' | 'deeplink'; // Function to parse deep link and extract recipe config function parseDeepLink(deepLink: string): Recipe | null { try { const url = new URL(deepLink); if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { return null; } const configParam = url.searchParams.get('config'); if (!configParam) { return null; } const configJson = Buffer.from(configParam, 'base64').toString('utf-8'); return JSON.parse(configJson) as Recipe; } catch (error) { console.error('Failed to parse deep link:', error); return null; } } // Function to convert recipe to YAML function recipeToYaml(recipe: Recipe): string { // Create a clean recipe object for YAML conversion const cleanRecipe: CleanRecipe = { title: recipe.title, description: recipe.description, instructions: recipe.instructions, }; if (recipe.prompt) { cleanRecipe.prompt = recipe.prompt; } if (recipe.activities && recipe.activities.length > 0) { cleanRecipe.activities = recipe.activities; } if (recipe.extensions && recipe.extensions.length > 0) { cleanRecipe.extensions = recipe.extensions.map((ext) => { const cleanExt: CleanExtension = { name: ext.name, type: 'builtin', // Default type, will be overridden below }; // Handle different extension types using type assertions if ('type' in ext && ext.type) { cleanExt.type = ext.type as CleanExtension['type']; // Use type assertions to access properties safely const extAny = ext as Record; if (ext.type === 'sse' && extAny.uri) { cleanExt.uri = extAny.uri as string; } else if (ext.type === 'stdio') { if (extAny.cmd) { cleanExt.cmd = extAny.cmd as string; } if (extAny.args) { cleanExt.args = extAny.args as string[]; } } else if (ext.type === 'builtin' && extAny.display_name) { cleanExt.display_name = extAny.display_name as string; } // Handle frontend type separately to avoid TypeScript narrowing issues if ((ext.type as string) === 'frontend') { if (extAny.tools) { cleanExt.tools = extAny.tools as unknown[]; } if (extAny.instructions) { cleanExt.instructions = extAny.instructions as string; } } } else { // Fallback: try to infer type from available fields const extAny = ext as Record; if (extAny.cmd) { cleanExt.type = 'stdio'; cleanExt.cmd = extAny.cmd as string; if (extAny.args) { cleanExt.args = extAny.args as string[]; } } else if (extAny.command) { // Handle legacy 'command' field by converting to 'cmd' cleanExt.type = 'stdio'; cleanExt.cmd = extAny.command as string; } else if (extAny.uri) { cleanExt.type = 'sse'; cleanExt.uri = extAny.uri as string; } else if (extAny.tools) { cleanExt.type = 'frontend'; cleanExt.tools = extAny.tools as unknown[]; if (extAny.instructions) { cleanExt.instructions = extAny.instructions as string; } } else { // Default to builtin if we can't determine type cleanExt.type = 'builtin'; } } // Add common optional fields if (ext.env_keys && ext.env_keys.length > 0) { cleanExt.env_keys = ext.env_keys; } if ('timeout' in ext && ext.timeout) { cleanExt.timeout = ext.timeout as number; } if ('description' in ext && ext.description) { cleanExt.description = ext.description as string; } if ('bundled' in ext && ext.bundled !== undefined) { cleanExt.bundled = ext.bundled as boolean; } return cleanExt; }); } if (recipe.goosehints) { cleanRecipe.goosehints = recipe.goosehints; } if (recipe.context && recipe.context.length > 0) { cleanRecipe.context = recipe.context; } if (recipe.profile) { cleanRecipe.profile = recipe.profile; } if (recipe.author) { cleanRecipe.author = recipe.author; } return yaml.stringify(cleanRecipe); } export const CreateScheduleModal: React.FC = ({ isOpen, onClose, onSubmit, isLoadingExternally, apiErrorExternally, }) => { const [scheduleId, setScheduleId] = useState(''); const [sourceType, setSourceType] = useState('file'); const [recipeSourcePath, setRecipeSourcePath] = useState(''); const [deepLinkInput, setDeepLinkInput] = useState(''); const [parsedRecipe, setParsedRecipe] = useState(null); const [frequency, setFrequency] = useState('daily'); const [selectedDate, setSelectedDate] = useState( () => new Date().toISOString().split('T')[0] ); const [selectedTime, setSelectedTime] = useState('09:00'); const [selectedMinute, setSelectedMinute] = useState('0'); const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState>(new Set(['1'])); const [selectedDayOfMonth, setSelectedDayOfMonth] = useState('1'); const [derivedCronExpression, setDerivedCronExpression] = useState(''); const [readableCronExpression, setReadableCronExpression] = useState(''); const [internalValidationError, setInternalValidationError] = useState(null); const handleDeepLinkChange = useCallback( (value: string) => { setDeepLinkInput(value); setInternalValidationError(null); if (value.trim()) { const recipe = parseDeepLink(value.trim()); if (recipe) { setParsedRecipe(recipe); // Auto-populate schedule ID from recipe title if available if (recipe.title && !scheduleId) { const cleanId = recipe.title .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-'); setScheduleId(cleanId); } } else { setParsedRecipe(null); setInternalValidationError( 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' ); } } else { setParsedRecipe(null); } }, [scheduleId] ); useEffect(() => { // Check for pending deep link when modal opens if (isOpen) { const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink'); if (pendingDeepLink) { localStorage.removeItem('pendingScheduleDeepLink'); setSourceType('deeplink'); handleDeepLinkChange(pendingDeepLink); } } }, [isOpen, handleDeepLinkChange]); const resetForm = () => { setScheduleId(''); setSourceType('file'); setRecipeSourcePath(''); setDeepLinkInput(''); setParsedRecipe(null); setFrequency('daily'); setSelectedDate(new Date().toISOString().split('T')[0]); setSelectedTime('09:00'); setSelectedMinute('0'); setSelectedDaysOfWeek(new Set(['1'])); setSelectedDayOfMonth('1'); setInternalValidationError(null); setReadableCronExpression(''); }; const handleBrowseFile = async () => { const filePath = await window.electron.selectFileOrDirectory(); if (filePath) { if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { setRecipeSourcePath(filePath); setInternalValidationError(null); } else { setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)'); console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)'); } } }; useEffect(() => { const generateCronExpression = (): string => { const timeParts = selectedTime.split(':'); const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0'; const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0'; if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) { return 'Invalid time format.'; } const secondsPart = '0'; switch (frequency) { case 'once': if (selectedDate && selectedTime) { try { const dateObj = new Date(`${selectedDate}T${selectedTime}`); if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'."; return `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${ dateObj.getMonth() + 1 } *`; } catch (e) { return "Error parsing date/time for 'once'."; } } return 'Date and Time are required for "Once" frequency.'; case 'hourly': { const sMinute = parseInt(selectedMinute, 10); if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) { return 'Invalid minute (0-59) for hourly frequency.'; } return `${secondsPart} ${sMinute} * * * *`; } case 'daily': return `${secondsPart} ${minutePart} ${hourPart} * * *`; case 'weekly': { if (selectedDaysOfWeek.size === 0) { return 'Select at least one day for weekly frequency.'; } const days = Array.from(selectedDaysOfWeek) .sort((a, b) => parseInt(a) - parseInt(b)) .join(','); return `${secondsPart} ${minutePart} ${hourPart} * * ${days}`; } case 'monthly': { const sDayOfMonth = parseInt(selectedDayOfMonth, 10); if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) { return 'Invalid day of month (1-31) for monthly frequency.'; } return `${secondsPart} ${minutePart} ${hourPart} ${sDayOfMonth} * *`; } default: return 'Invalid frequency selected.'; } }; const cron = generateCronExpression(); setDerivedCronExpression(cron); try { if ( cron.includes('Invalid') || cron.includes('required') || cron.includes('Error') || cron.includes('Select at least one') ) { setReadableCronExpression('Invalid cron details provided.'); } else { setReadableCronExpression(cronstrue.toString(cron)); } } catch (e) { setReadableCronExpression('Could not parse cron string.'); } }, [ frequency, selectedDate, selectedTime, selectedMinute, selectedDaysOfWeek, selectedDayOfMonth, ]); const handleDayOfWeekChange = (dayValue: string) => { setSelectedDaysOfWeek((prev) => { const newSet = new Set(prev); if (newSet.has(dayValue)) { newSet.delete(dayValue); } else { newSet.add(dayValue); } return newSet; }); }; const handleLocalSubmit = async (event: FormEvent) => { event.preventDefault(); setInternalValidationError(null); if (!scheduleId.trim()) { setInternalValidationError('Schedule ID is required.'); return; } let finalRecipeSource = ''; if (sourceType === 'file') { if (!recipeSourcePath) { setInternalValidationError('Recipe source file is required.'); return; } finalRecipeSource = recipeSourcePath; } else if (sourceType === 'deeplink') { if (!deepLinkInput.trim()) { setInternalValidationError('Deep link is required.'); return; } if (!parsedRecipe) { setInternalValidationError('Invalid deep link. Please check the format.'); return; } try { // Convert recipe to YAML and save to a temporary file const yamlContent = recipeToYaml(parsedRecipe); console.log('Generated YAML content:', yamlContent); // Debug log const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; const tempFilePath = `${tempDir}/${tempFileName}`; // Write the YAML file const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); if (!writeSuccess) { setInternalValidationError('Failed to create temporary recipe file.'); return; } finalRecipeSource = tempFilePath; } catch (error) { console.error('Failed to convert recipe to YAML:', error); setInternalValidationError('Failed to process the recipe from deep link.'); return; } } if ( !derivedCronExpression || derivedCronExpression.includes('Invalid') || derivedCronExpression.includes('required') || derivedCronExpression.includes('Error') || derivedCronExpression.includes('Select at least one') ) { setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`); return; } if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) { setInternalValidationError('For weekly frequency, select at least one day.'); return; } const newSchedulePayload: NewSchedulePayload = { id: scheduleId.trim(), recipe_source: finalRecipeSource, cron: derivedCronExpression, }; await onSubmit(newSchedulePayload); }; const handleClose = () => { resetForm(); onClose(); }; if (!isOpen) return null; return (

Create New Schedule

{apiErrorExternally && (

{apiErrorExternally}

)} {internalValidationError && (

{internalValidationError}

)}
setScheduleId(e.target.value)} placeholder="e.g., daily-summary-job" required />
{sourceType === 'file' && (
{recipeSourcePath && (

Selected: {recipeSourcePath}

)}
)} {sourceType === 'deeplink' && (
handleDeepLinkChange(e.target.value)} placeholder="Paste goose://bot or goose://recipe link here..." /> {parsedRecipe && (

✓ Recipe parsed successfully

Title: {parsedRecipe.title}

Description: {parsedRecipe.description}

)}
)}
setSelectedDate(e.target.value)} required />
setSelectedTime(e.target.value)} required />
)} {frequency === 'hourly' && (
setSelectedMinute(e.target.value)} required />
)} {(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
setSelectedTime(e.target.value)} required />
)} {frequency === 'weekly' && (
{daysOfWeekOptions.map((day) => ( ))}
)} {frequency === 'monthly' && (
setSelectedDayOfMonth(e.target.value)} required />
)}

Generated Cron:{' '} {derivedCronExpression}

Human Readable: {readableCronExpression}

Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)

{frequency === 'once' && (

Note: "Once" schedules recur annually. True one-time tasks may need backend deletion after execution.

)}
{/* Actions */}
); };