diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ace1228668a6..2a21b8266304 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -20,6 +20,7 @@ import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; +import RecipesView from './components/RecipesView'; import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; @@ -45,6 +46,7 @@ export type View = | 'sharedSession' | 'loading' | 'recipeEditor' + | 'recipes' | 'permission'; export type ViewOptions = { @@ -557,6 +559,7 @@ export default function App() { config={(viewOptions?.config as Recipe) || window.electron.getConfig().recipeConfig} /> )} + {view === 'recipes' && setView('chat')} />} {view === 'permission' && ( setView((viewOptions as { parentView: View }).parentView)} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 222cb994eb8f..2044dab9e6d8 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -246,7 +246,8 @@ function ChatContent({ console.log('Opening recipe editor with config:', response.recipe); const recipeConfig = { id: response.recipe.title || 'untitled', - name: response.recipe.title || 'Untitled Recipe', + name: response.recipe.title || 'Untitled Recipe', // Does not exist on recipe type + title: response.recipe.title || 'Untitled Recipe', description: response.recipe.description || '', instructions: response.recipe.instructions || '', activities: response.recipe.activities || [], diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx new file mode 100644 index 000000000000..9c2e110f6779 --- /dev/null +++ b/ui/desktop/src/components/RecipesView.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from 'react'; +import { listSavedRecipes, archiveRecipe, SavedRecipe } from '../recipe/recipeStorage'; +import { FileText, Trash2, Bot, Calendar, Globe, Folder } from 'lucide-react'; +import { ScrollArea } from './ui/scroll-area'; +import BackButton from './ui/BackButton'; +import MoreMenuLayout from './more_menu/MoreMenuLayout'; + +interface RecipesViewProps { + onBack: () => void; +} + +export default function RecipesView({ onBack }: RecipesViewProps) { + const [savedRecipes, setSavedRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); + const [showPreview, setShowPreview] = useState(false); + + useEffect(() => { + loadSavedRecipes(); + }, []); + + const loadSavedRecipes = async () => { + try { + setLoading(true); + setError(null); + const recipes = await listSavedRecipes(); + setSavedRecipes(recipes); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load recipes'); + console.error('Failed to load saved recipes:', err); + } finally { + setLoading(false); + } + }; + + const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { + try { + // Use the recipe directly - no need for manual mapping + window.electron.createChatWindow( + undefined, // query + undefined, // dir + undefined, // version + undefined, // resumeSessionId + savedRecipe.recipe, // recipe config + undefined // view type + ); + } catch (err) { + console.error('Failed to load recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to load recipe'); + } + }; + + const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { + // TODO: Use Electron's dialog API for confirmation + const result = await window.electron.showMessageBox({ + type: 'warning', + buttons: ['Cancel', 'Delete'], + defaultId: 0, + title: 'Delete Recipe', + message: `Are you sure you want to delete "${savedRecipe.name}"?`, + detail: 'Deleted recipes can be restored later.', + }); + + if (result.response !== 1) { + return; + } + + try { + await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal); + // Reload the recipes list + await loadSavedRecipes(); + } catch (err) { + console.error('Failed to archive recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to archive recipe'); + } + }; + + const handlePreviewRecipe = (savedRecipe: SavedRecipe) => { + setSelectedRecipe(savedRecipe); + setShowPreview(true); + }; + + if (loading) { + return ( +
+ +
+
+

Loading recipes...

+
+
+ ); + } + + if (error) { + return ( +
+ +
+

{error}

+ +
+
+ ); + } + + return ( +
+ + + +
+
+ +

Saved Recipes

+
+ + {/* Content Area */} +
+ {savedRecipes.length === 0 ? ( +
+ +

No saved recipes

+

+ Save a recipe from an active session to see it here. +

+
+ ) : ( +
+ {savedRecipes.map((savedRecipe) => ( +
+
+
+
+

+ {savedRecipe.recipe.title} +

+ {savedRecipe.isGlobal ? ( + + ) : ( + + )} +
+

{savedRecipe.recipe.description}

+
+ + {savedRecipe.lastModified.toLocaleDateString()} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+
+ + {/* Preview Modal */} + {showPreview && selectedRecipe && ( +
+
+
+
+

+ {selectedRecipe.recipe.title} +

+

+ {selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'} +

+
+ +
+ +
+
+

Description

+

{selectedRecipe.recipe.description}

+
+ + {selectedRecipe.recipe.instructions && ( +
+

Instructions

+
+
+                      {selectedRecipe.recipe.instructions}
+                    
+
+
+ )} + + {selectedRecipe.recipe.prompt && ( +
+

Initial Prompt

+
+
+                      {selectedRecipe.recipe.prompt}
+                    
+
+
+ )} + + {selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && ( +
+

Activities

+
+ {selectedRecipe.recipe.activities.map((activity, index) => ( + + {activity} + + ))} +
+
+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/more_menu/MoreMenu.tsx b/ui/desktop/src/components/more_menu/MoreMenu.tsx index a54ed55238f8..643166f923c0 100644 --- a/ui/desktop/src/components/more_menu/MoreMenu.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenu.tsx @@ -1,18 +1,14 @@ import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '../ui/popover'; import React, { useEffect, useState } from 'react'; import { ChatSmart, Idea, Refresh, Time, Send, Settings } from '../icons'; -import { FolderOpen, Moon, Sliders, Sun } from 'lucide-react'; +import { FolderOpen, Moon, Sliders, Sun, Save, FileText } from 'lucide-react'; import { useConfig } from '../ConfigContext'; import { ViewOptions, View } from '../../App'; +import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage'; +import { Recipe } from '../../recipe'; -interface RecipeConfig { - id: string; - name: string; - description: string; - instructions?: string; - activities?: string[]; - [key: string]: unknown; -} +// RecipeConfig is used for window creation and should match Recipe interface +type RecipeConfig = Recipe; interface MenuButtonProps { onClick: () => void; @@ -113,6 +109,10 @@ export default function MoreMenu({ setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { const [open, setOpen] = useState(false); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [saveRecipeName, setSaveRecipeName] = useState(''); + const [saveGlobal, setSaveGlobal] = useState(true); + const [saving, setSaving] = useState(false); const { remove } = useConfig(); const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => { const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true'; @@ -167,6 +167,72 @@ export default function MoreMenu({ const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { setThemeMode(newTheme); }; + + const handleSaveRecipe = async () => { + if (!saveRecipeName.trim()) { + return; + } + + setSaving(true); + try { + // Get the current recipe config from the window with proper validation + const currentRecipeConfig = window.appConfig.get('recipeConfig'); + + if (!currentRecipeConfig || typeof currentRecipeConfig !== 'object') { + throw new Error('No recipe configuration found'); + } + + // Validate that it has the required Recipe properties + const recipe = currentRecipeConfig as Recipe; + if (!recipe.title || !recipe.description || !recipe.instructions) { + throw new Error('Invalid recipe configuration: missing required fields'); + } + + // Save the recipe + const filePath = await saveRecipe(recipe, { + name: saveRecipeName.trim(), + global: saveGlobal, + }); + + // Show success message (you might want to use a toast notification instead) + console.log(`Recipe saved to: ${filePath}`); + + // Reset dialog state + setShowSaveDialog(false); + setSaveRecipeName(''); + setOpen(false); + + // Optional: Show a success notification + window.electron.showNotification({ + title: 'Recipe Saved', + body: `Recipe "${saveRecipeName}" has been saved successfully.`, + }); + } catch (error) { + console.error('Failed to save recipe:', error); + + // Show error notification + window.electron.showNotification({ + title: 'Save Failed', + body: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } finally { + setSaving(false); + } + }; + + const handleSaveRecipeClick = () => { + const currentRecipeConfig = window.appConfig.get('recipeConfig'); + + if (currentRecipeConfig && typeof currentRecipeConfig === 'object') { + const recipe = currentRecipeConfig as Recipe; + // Generate a suggested name from the recipe title + const suggestedName = generateRecipeFilename(recipe); + setSaveRecipeName(suggestedName); + setShowSaveDialog(true); + setOpen(false); + } + }; + const recipeConfig = window.appConfig.get('recipeConfig'); return ( @@ -245,23 +311,33 @@ export default function MoreMenu({ {recipeConfig ? ( - { - setOpen(false); - window.electron.createChatWindow( - undefined, // query - undefined, // dir - undefined, // version - undefined, // resumeSessionId - recipeConfig as RecipeConfig, // recipe config - 'recipeEditor' // view type - ); - }} - subtitle="View the recipe you're using" - icon={} - > - View recipe - + <> + { + setOpen(false); + window.electron.createChatWindow( + undefined, // query + undefined, // dir + undefined, // version + undefined, // resumeSessionId + recipeConfig as RecipeConfig, // recipe config + 'recipeEditor' // view type + ); + }} + subtitle="View the recipe you're using" + icon={} + > + View recipe + + + } + > + Save recipe + + ) : ( { @@ -276,6 +352,16 @@ export default function MoreMenu({ Make Agent from this session )} + { + setOpen(false); + setView('recipes'); + }} + subtitle="Browse your saved recipes" + icon={} + > + Go to Recipe Library + { setOpen(false); @@ -310,6 +396,87 @@ export default function MoreMenu({ + + {/* Save Recipe Dialog */} + {showSaveDialog && ( +
+
+

Save Recipe

+ +
+
+ + setSaveRecipeName(e.target.value)} + className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent" + placeholder="Enter recipe name" + autoFocus + /> +
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ )}
); } diff --git a/ui/desktop/src/components/ui/DeepLinkModal.tsx b/ui/desktop/src/components/ui/DeepLinkModal.tsx index 0bf2063c9674..1c112febe47a 100644 --- a/ui/desktop/src/components/ui/DeepLinkModal.tsx +++ b/ui/desktop/src/components/ui/DeepLinkModal.tsx @@ -138,6 +138,7 @@ export function DeepLinkModal({ recipeConfig: initialRecipeConfig, onClose }: De const currentConfig = { id: 'deeplink-recipe', name: 'DeepLink Recipe', + title: 'DeepLink Recipe', description: 'Recipe from deep link', ...recipeConfig, instructions, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 630f208b7cc6..df1565864857 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1069,7 +1069,12 @@ ipcMain.handle('get-binary-path', (_event, binaryName) => { ipcMain.handle('read-file', (_event, filePath) => { return new Promise((resolve) => { - const cat = spawn('cat', [filePath]); + // Expand tilde to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; + + const cat = spawn('cat', [expandedPath]); let output = ''; let errorOutput = ''; @@ -1084,26 +1089,31 @@ ipcMain.handle('read-file', (_event, filePath) => { cat.on('close', (code) => { if (code !== 0) { // File not found or error - resolve({ file: '', filePath, error: errorOutput || null, found: false }); + resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false }); return; } - resolve({ file: output, filePath, error: null, found: true }); + resolve({ file: output, filePath: expandedPath, error: null, found: true }); }); cat.on('error', (error) => { console.error('Error reading file:', error); - resolve({ file: '', filePath, error, found: false }); + resolve({ file: '', filePath: expandedPath, error, found: false }); }); }); }); ipcMain.handle('write-file', (_event, filePath, content) => { return new Promise((resolve) => { + // Expand tilde to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; + // Create a write stream to the file // eslint-disable-next-line @typescript-eslint/no-var-requires const fsNode = require('fs'); // Using require for fs in this specific handler from original try { - fsNode.writeFileSync(filePath, content, { encoding: 'utf8' }); + fsNode.writeFileSync(expandedPath, content, { encoding: 'utf8' }); resolve(true); } catch (error) { console.error('Error writing to file:', error); @@ -1112,6 +1122,46 @@ ipcMain.handle('write-file', (_event, filePath, content) => { }); }); +// Enhanced file operations +ipcMain.handle('ensure-directory', async (_event, dirPath) => { + try { + // Expand tilde to home directory + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; + + await fs.mkdir(expandedPath, { recursive: true }); + return true; + } catch (error) { + console.error('Error creating directory:', error); + return false; + } +}); + +ipcMain.handle('list-files', async (_event, dirPath, extension) => { + try { + // Expand tilde to home directory + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; + + const files = await fs.readdir(expandedPath); + if (extension) { + return files.filter((file) => file.endsWith(extension)); + } + return files; + } catch (error) { + console.error('Error listing files:', error); + return []; + } +}); + +// Handle message box dialogs +ipcMain.handle('show-message-box', async (_event, options) => { + const result = await dialog.showMessageBox(options); + return result; +}); + // Handle allowed extensions list fetching ipcMain.handle('get-allowed-extensions', async () => { try { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index d79f68a5d6fc..89276409c8c2 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,19 +1,28 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { Recipe } from './recipe'; -interface RecipeConfig { - id: string; - name: string; - description: string; - instructions?: string; - activities?: string[]; - [key: string]: unknown; -} +// RecipeConfig is used for window creation and should match Recipe interface +type RecipeConfig = Recipe; interface NotificationData { title: string; body: string; } +interface MessageBoxOptions { + type?: 'none' | 'info' | 'error' | 'question' | 'warning'; + buttons?: string[]; + defaultId?: number; + title?: string; + message: string; + detail?: string; +} + +interface MessageBoxResponse { + response: number; + checkboxChecked?: boolean; +} + interface FileResponse { file: string; filePath: string; @@ -51,6 +60,7 @@ type ElectronAPI = { ) => void; logInfo: (txt: string) => void; showNotification: (data: NotificationData) => void; + showMessageBox: (options: MessageBoxOptions) => Promise; openInChrome: (url: string) => void; fetchMetadata: (url: string) => Promise; reloadApp: () => void; @@ -61,6 +71,8 @@ type ElectronAPI = { getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; + ensureDirectory: (dirPath: string) => Promise; + listFiles: (dirPath: string, extension?: string) => Promise; getAllowedExtensions: () => Promise; getPathForFile: (file: File) => string; setMenuBarIcon: (show: boolean) => Promise; @@ -121,6 +133,7 @@ const electronAPI: ElectronAPI = { ), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), + showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url), fetchMetadata: (url: string) => ipcRenderer.invoke('fetch-metadata', url), reloadApp: () => ipcRenderer.send('reload-app'), @@ -132,6 +145,9 @@ const electronAPI: ElectronAPI = { readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), + ensureDirectory: (dirPath: string) => ipcRenderer.invoke('ensure-directory', dirPath), + listFiles: (dirPath: string, extension?: string) => + ipcRenderer.invoke('list-files', dirPath, extension), getPathForFile: (file: File) => webUtils.getPathForFile(file), getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'), setMenuBarIcon: (show: boolean) => ipcRenderer.invoke('set-menu-bar-icon', show), diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts new file mode 100644 index 000000000000..0d92c6808982 --- /dev/null +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -0,0 +1,339 @@ +import { Recipe } from './index'; +import * as yaml from 'yaml'; + +export interface SaveRecipeOptions { + name: string; + global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/) +} + +export interface SavedRecipe { + name: string; + recipe: Recipe; + isGlobal: boolean; + lastModified: Date; + isArchived?: boolean; +} + +/** + * Sanitize a recipe name to be safe for use as a filename + */ +function sanitizeRecipeName(name: string): string { + return name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim(); +} + +/** + * Parse a lastModified value that could be a string or Date + */ +function parseLastModified(val: string | Date): Date { + return val instanceof Date ? val : new Date(val); +} + +/** + * Get the storage directory path for recipes + */ +function getStorageDirectory(isGlobal: boolean): string { + return isGlobal ? '~/.config/goose/recipes' : '.goose/recipes'; +} + +/** + * Get the file path for a recipe based on its name + */ +function getRecipeFilePath(recipeName: string, isGlobal: boolean): string { + const dir = getStorageDirectory(isGlobal); + return `${dir}/${recipeName}.yaml`; +} + +/** + * Load recipe from file + */ +async function loadRecipeFromFile( + recipeName: string, + isGlobal: boolean +): Promise { + const filePath = getRecipeFilePath(recipeName, isGlobal); + + try { + const result = await window.electron.readFile(filePath); + if (!result.found || result.error) { + return null; + } + + const recipeData = yaml.parse(result.file) as SavedRecipe; + + // Convert lastModified string to Date if needed + recipeData.lastModified = parseLastModified(recipeData.lastModified); + + return { + ...recipeData, + isGlobal: isGlobal, + }; + } catch (error) { + console.warn(`Failed to load recipe from ${filePath}:`, error); + return null; + } +} + +/** + * Save recipe to file + */ +async function saveRecipeToFile(recipe: SavedRecipe): Promise { + const filePath = getRecipeFilePath(recipe.name, recipe.isGlobal); + + // Ensure directory exists + const dirPath = getStorageDirectory(recipe.isGlobal); + await window.electron.ensureDirectory(dirPath); + + // Convert to YAML and save + const yamlContent = yaml.stringify(recipe); + return await window.electron.writeFile(filePath, yamlContent); +} +/** + * Save a recipe to a file using IPC. + */ +export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise { + const { name, global = true } = options; + + // Sanitize name + const sanitizedName = sanitizeRecipeName(name); + if (!sanitizedName) { + throw new Error('Invalid recipe name'); + } + + // Validate recipe has required fields + if (!recipe.title || !recipe.description || !recipe.instructions) { + throw new Error('Recipe is missing required fields (title, description, instructions)'); + } + + try { + // Create saved recipe object + const savedRecipe: SavedRecipe = { + name: sanitizedName, + recipe: recipe, + isGlobal: global, + lastModified: new Date(), + isArchived: false, + }; + + // Save to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to save recipe file'); + } + + // Return identifier for the saved recipe + return `${global ? 'global' : 'local'}:${sanitizedName}`; + } catch (error) { + throw new Error( + `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Load a recipe by name from file. + */ +export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + // Validate the loaded recipe has required fields + if ( + !savedRecipe.recipe.title || + !savedRecipe.recipe.description || + !savedRecipe.recipe.instructions + ) { + throw new Error('Loaded recipe is missing required fields'); + } + + return savedRecipe.recipe; + } catch (error) { + throw new Error( + `Failed to load recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * List all saved recipes from the recipes directories. + * + * Uses the listFiles API to find available recipe files. + */ +export async function listSavedRecipes(includeArchived: boolean = false): Promise { + const recipes: SavedRecipe[] = []; + + try { + // Check for global and local recipe directories + const globalDir = getStorageDirectory(true); + const localDir = getStorageDirectory(false); + + // Ensure directories exist + await window.electron.ensureDirectory(globalDir); + await window.electron.ensureDirectory(localDir); + + // Get list of recipe files with .yaml extension + const globalFiles = await window.electron.listFiles(globalDir, 'yaml'); + const localFiles = await window.electron.listFiles(localDir, 'yaml'); + + // Process global recipes + for (const file of globalFiles) { + const recipeName = file.replace(/\.yaml$/, ''); + const recipe = await loadRecipeFromFile(recipeName, true); + if (recipe && (includeArchived || !recipe.isArchived)) { + recipes.push(recipe); + } + } + + // Process local recipes + for (const file of localFiles) { + const recipeName = file.replace(/\.yaml$/, ''); + const recipe = await loadRecipeFromFile(recipeName, false); + if (recipe && (includeArchived || !recipe.isArchived)) { + recipes.push(recipe); + } + } + + // Sort by last modified (newest first) + return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + } catch (error) { + console.warn('Failed to list saved recipes:', error); + return []; + } +} + +/** + * Restore an archived recipe. + * + * @param recipeName The name of the recipe to restore + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function restoreRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Archived recipe not found'); + } + + if (!savedRecipe.isArchived) { + throw new Error('Recipe is not archived'); + } + + // Mark as not archived + savedRecipe.isArchived = false; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to save updated recipe'); + } + } catch (error) { + throw new Error( + `Failed to restore recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Archive a recipe. + * + * @param recipeName The name of the recipe to archive + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function archiveRecipe(recipeName: string, isGlobal: boolean): Promise { + try { + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + if (savedRecipe.isArchived) { + throw new Error('Recipe is already archived'); + } + + // Mark as archived + savedRecipe.isArchived = true; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to save updated recipe'); + } + } catch (error) { + throw new Error( + `Failed to archive recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Permanently delete a recipe file. + * + * @param recipeName The name of the recipe to permanently delete + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function permanentlyDeleteRecipe( + recipeName: string, + isGlobal: boolean +): Promise { + try { + // TODO: Implement file deletion when available in the API + // For now, we'll just mark it as archived as a fallback + const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); + + if (!savedRecipe) { + throw new Error('Recipe not found'); + } + + // Mark as archived with special flag + savedRecipe.isArchived = true; + savedRecipe.lastModified = new Date(); + + // Save back to file + const success = await saveRecipeToFile(savedRecipe); + + if (!success) { + throw new Error('Failed to mark recipe as deleted'); + } + } catch (error) { + throw new Error( + `Failed to delete recipe: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete a recipe (archives it by default for backward compatibility). + * + * @deprecated Use archiveRecipe instead + * @param recipeName The name of the recipe to delete/archive + * @param isGlobal Whether the recipe is in global or local storage + */ +export async function deleteRecipe(recipeName: string, isGlobal: boolean): Promise { + return archiveRecipe(recipeName, isGlobal); +} + +/** + * Generate a suggested filename for a recipe based on its title. + * + * @param recipe The recipe to generate a filename for + * @returns A sanitized filename suitable for use as a recipe name + */ +export function generateRecipeFilename(recipe: Recipe): string { + const baseName = recipe.title + .toLowerCase() + .replace(/[^a-zA-Z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .trim(); + + return baseName || 'untitled-recipe'; +}