Add recipe title in import form (#4625)
This commit is contained in:
@@ -9,8 +9,8 @@ import { saveRecipe } from '../../recipe/recipeStorage';
|
|||||||
import * as yaml from 'yaml';
|
import * as yaml from 'yaml';
|
||||||
import { toastSuccess, toastError } from '../../toasts';
|
import { toastSuccess, toastError } from '../../toasts';
|
||||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||||
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
|
import { RecipeTitleField } from './shared/RecipeTitleField';
|
||||||
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
|
import { listSavedRecipes } from '../../recipe/recipeStorage';
|
||||||
import {
|
import {
|
||||||
validateRecipe,
|
validateRecipe,
|
||||||
getValidationErrorMessages,
|
getValidationErrorMessages,
|
||||||
@@ -39,7 +39,15 @@ const importRecipeSchema = z
|
|||||||
if (!file) return true;
|
if (!file) return true;
|
||||||
return file.size <= 1024 * 1024;
|
return file.size <= 1024 * 1024;
|
||||||
}, 'File is too large, max size is 1MB'),
|
}, 'File is too large, max size is 1MB'),
|
||||||
recipeName: recipeNameSchema,
|
recipeTitle: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Recipe title is required')
|
||||||
|
.max(100, 'Recipe title must be 100 characters or less')
|
||||||
|
.refine((title) => title.trim().length > 0, 'Recipe title cannot be empty')
|
||||||
|
.refine(
|
||||||
|
(title) => /^[^<>:"/\\|?*]+$/.test(title.trim()),
|
||||||
|
'Recipe title contains invalid characters (< > : " / \\ | ? *)'
|
||||||
|
),
|
||||||
global: z.boolean(),
|
global: z.boolean(),
|
||||||
})
|
})
|
||||||
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
|
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
|
||||||
@@ -111,11 +119,34 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
return recipe as Recipe;
|
return recipe as Recipe;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateTitleUniqueness = async (
|
||||||
|
title: string,
|
||||||
|
isGlobal: boolean
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (!title.trim()) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingRecipes = await listSavedRecipes();
|
||||||
|
const titleExists = existingRecipes.some(
|
||||||
|
(recipe) =>
|
||||||
|
recipe.recipe.title?.toLowerCase() === title.toLowerCase() && recipe.isGlobal === isGlobal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (titleExists) {
|
||||||
|
return `A recipe with the same title already exists`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to validate title uniqueness:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const importRecipeForm = useForm({
|
const importRecipeForm = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
deeplink: '',
|
deeplink: '',
|
||||||
recipeUploadFile: null as File | null,
|
recipeUploadFile: null as File | null,
|
||||||
recipeName: '',
|
recipeTitle: '',
|
||||||
global: true,
|
global: true,
|
||||||
},
|
},
|
||||||
validators: {
|
validators: {
|
||||||
@@ -138,6 +169,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
|
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recipe.title = value.recipeTitle.trim();
|
||||||
|
|
||||||
|
const titleValidationError = await validateTitleUniqueness(
|
||||||
|
value.recipeTitle.trim(),
|
||||||
|
value.global
|
||||||
|
);
|
||||||
|
if (titleValidationError) {
|
||||||
|
throw new Error(titleValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
const validationResult = validateRecipe(recipe);
|
const validationResult = validateRecipe(recipe);
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
const errorMessages = getValidationErrorMessages(validationResult.errors);
|
const errorMessages = getValidationErrorMessages(validationResult.errors);
|
||||||
@@ -145,7 +186,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
}
|
}
|
||||||
|
|
||||||
await saveRecipe(recipe, {
|
await saveRecipe(recipe, {
|
||||||
name: value.recipeName.trim(),
|
name: '',
|
||||||
|
title: value.recipeTitle.trim(),
|
||||||
global: value.global,
|
global: value.global,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +195,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
importRecipeForm.reset({
|
importRecipeForm.reset({
|
||||||
deeplink: '',
|
deeplink: '',
|
||||||
recipeUploadFile: null,
|
recipeUploadFile: null,
|
||||||
recipeName: '',
|
recipeTitle: '',
|
||||||
global: true,
|
global: true,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -161,7 +203,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
|
|
||||||
toastSuccess({
|
toastSuccess({
|
||||||
title: value.recipeName.trim(),
|
title: value.recipeTitle.trim(),
|
||||||
msg: 'Recipe imported successfully',
|
msg: 'Recipe imported successfully',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,16 +225,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
importRecipeForm.reset({
|
importRecipeForm.reset({
|
||||||
deeplink: '',
|
deeplink: '',
|
||||||
recipeUploadFile: null,
|
recipeUploadFile: null,
|
||||||
recipeName: '',
|
recipeTitle: '',
|
||||||
global: true,
|
global: true,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store reference to recipe name field for programmatic updates
|
// Store reference to recipe title field for programmatic updates
|
||||||
let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null;
|
let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null;
|
||||||
|
|
||||||
// Auto-generate recipe name when deeplink changes
|
// Auto-populate recipe title when deeplink changes
|
||||||
const handleDeeplinkChange = async (
|
const handleDeeplinkChange = async (
|
||||||
value: string,
|
value: string,
|
||||||
field: { handleChange: (value: string) => void }
|
field: { handleChange: (value: string) => void }
|
||||||
@@ -204,13 +246,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
try {
|
try {
|
||||||
const recipe = await parseDeeplink(value.trim());
|
const recipe = await parseDeeplink(value.trim());
|
||||||
if (recipe && recipe.title) {
|
if (recipe && recipe.title) {
|
||||||
const suggestedName = generateRecipeNameFromTitle(recipe.title);
|
// Use the recipe title field's handleChange method if available
|
||||||
|
if (recipeTitleFieldRef) {
|
||||||
// Use the recipe name field's handleChange method if available
|
recipeTitleFieldRef.handleChange(recipe.title);
|
||||||
if (recipeNameFieldRef) {
|
|
||||||
recipeNameFieldRef.handleChange(suggestedName);
|
|
||||||
} else {
|
} else {
|
||||||
importRecipeForm.setFieldValue('recipeName', suggestedName);
|
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,11 +258,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
console.log('Could not parse deeplink for auto-suggest:', error);
|
console.log('Could not parse deeplink for auto-suggest:', error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clear the recipe name when deeplink is empty
|
// Clear the recipe title when deeplink is empty
|
||||||
if (recipeNameFieldRef) {
|
if (recipeTitleFieldRef) {
|
||||||
recipeNameFieldRef.handleChange('');
|
recipeTitleFieldRef.handleChange('');
|
||||||
} else {
|
} else {
|
||||||
importRecipeForm.setFieldValue('recipeName', '');
|
importRecipeForm.setFieldValue('recipeTitle', '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,13 +275,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
const fileContent = await file.text();
|
const fileContent = await file.text();
|
||||||
const recipe = await parseRecipeUploadFile(fileContent, file.name);
|
const recipe = await parseRecipeUploadFile(fileContent, file.name);
|
||||||
if (recipe.title) {
|
if (recipe.title) {
|
||||||
const suggestedName = generateRecipeNameFromTitle(recipe.title);
|
// Use the recipe title field's handleChange method if available
|
||||||
|
if (recipeTitleFieldRef) {
|
||||||
// Use the recipe name field's handleChange method if available
|
recipeTitleFieldRef.handleChange(recipe.title);
|
||||||
if (recipeNameFieldRef) {
|
|
||||||
recipeNameFieldRef.handleChange(suggestedName);
|
|
||||||
} else {
|
} else {
|
||||||
importRecipeForm.setFieldValue('recipeName', suggestedName);
|
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -249,11 +287,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
console.log('Could not parse recipe file for auto-suggest:', error);
|
console.log('Could not parse recipe file for auto-suggest:', error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clear the recipe name when file is removed
|
// Clear the recipe title when file is removed
|
||||||
if (recipeNameFieldRef) {
|
if (recipeTitleFieldRef) {
|
||||||
recipeNameFieldRef.handleChange('');
|
recipeTitleFieldRef.handleChange('');
|
||||||
} else {
|
} else {
|
||||||
importRecipeForm.setFieldValue('recipeName', '');
|
importRecipeForm.setFieldValue('recipeTitle', '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -397,14 +435,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
interface.
|
interface.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<importRecipeForm.Field name="recipeName">
|
<importRecipeForm.Field name="recipeTitle">
|
||||||
{(field) => {
|
{(field) => {
|
||||||
// Store reference to the field for programmatic updates
|
// Store reference to the field for programmatic updates
|
||||||
recipeNameFieldRef = field;
|
recipeTitleFieldRef = field;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecipeNameField
|
<RecipeTitleField
|
||||||
id="import-recipe-name"
|
id="import-recipe-title"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={field.handleChange}
|
onChange={field.handleChange}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
@@ -491,7 +529,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
|
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
|
||||||
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto whitespace-pre font-mono">
|
<pre className="text-xs bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
|
||||||
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
|
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
<p className="mt-4 text-blue-700 text-sm">
|
<p className="mt-4 text-blue-700 text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
interface RecipeTitleFieldProps {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
errors: string[];
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipeTitleField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
errors,
|
||||||
|
label = 'Recipe Title',
|
||||||
|
required = true,
|
||||||
|
disabled = false,
|
||||||
|
}: RecipeTitleFieldProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
|
||||||
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
|
errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
placeholder="My Recipe Title"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
This will be the display name shown in your recipe library
|
||||||
|
</p>
|
||||||
|
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { validateRecipe, getValidationErrorMessages } from './validation';
|
|||||||
|
|
||||||
export interface SaveRecipeOptions {
|
export interface SaveRecipeOptions {
|
||||||
name: string;
|
name: string;
|
||||||
|
title?: string;
|
||||||
global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
|
global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +71,23 @@ async function saveRecipeToFile(recipe: SavedRecipe): Promise<boolean> {
|
|||||||
* Save a recipe to a file using IPC.
|
* Save a recipe to a file using IPC.
|
||||||
*/
|
*/
|
||||||
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
|
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
|
||||||
const { name, global = true } = options;
|
const { name, title, global = true } = options;
|
||||||
|
|
||||||
// Sanitize name
|
let sanitizedName: string;
|
||||||
const sanitizedName = sanitizeRecipeName(name);
|
|
||||||
if (!sanitizedName) {
|
if (title) {
|
||||||
throw new Error('Invalid recipe name');
|
recipe.title = title.trim();
|
||||||
|
sanitizedName = generateRecipeFilename(recipe);
|
||||||
|
if (!sanitizedName) {
|
||||||
|
throw new Error('Invalid recipe title - cannot generate filename');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This branch should now be considered deprecated and will be removed once the same functionality
|
||||||
|
// is incorporated in CreateRecipeForm
|
||||||
|
sanitizedName = sanitizeRecipeName(name);
|
||||||
|
if (!sanitizedName) {
|
||||||
|
throw new Error('Invalid recipe name');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationResult = validateRecipe(recipe);
|
const validationResult = validateRecipe(recipe);
|
||||||
|
|||||||
Reference in New Issue
Block a user