OmniFocus & ChatGPT: Recipe Tasks

Here’s an example of integrating the use of Apple’s Writing Tools with ChatGPT Compose with OmniFocus. In this scenario, a composition request to ChatGPT returns a requested recipe whose contents are converted into a new task containing information about the ingredients as well as step-by-step instructions for creating and cooking the dish!

An OmniFocus inbox task that is a recipe for Sole Almandine

It’s how you ask the question.

With AI services like ChatGPT, how a query or request is phrased makes a big difference in the results returned. It’s important to communicate in the query (also called a “prompt”) the format in which you would like to be applied to the results.

Do you want the results as stylized text? Or perhaps they should be returned as structured data that could be used to create other types of objects, like projects or tasks in OmniFocus?

In this example, new tasks displaying the elements and steps of food recipes are created from the data returned from using the Writing Tool’s “Compose” function connected to ChatGPT. Since the recipe data will not be used in OmniFocus as text, we’ll ask for it as Structured Data (XML) that can be parsed by Omni Automation to create a task with notes and sub-task steps.

So the composition query or “prompt” would always begin with this phrase:

“Provide in XML format, a recipe for…”

So the prompt: “Provide in XML format, a recipe for Sole Almandine.”

Returns results similar to this structured XML:

ChatGPT Recipe in XML Format


<recipe> <title>Sole Almandine</title> <servings>4</servings> <ingredients> <ingredient> <name>Sole fillets</name> <quantity>4</quantity> <unit>pieces</unit> </ingredient> <ingredient> <name>All-purpose flour</name> <quantity>1/2</quantity> <unit>cup</unit> </ingredient> <ingredient> <name>Salt</name> <quantity>1/2</quantity> <unit>teaspoon</unit> </ingredient> <ingredient> <name>Black pepper</name> <quantity>1/4</quantity> <unit>teaspoon</unit> </ingredient> <ingredient> <name>Butter</name> <quantity>4</quantity> <unit>tablespoons</unit> </ingredient> <ingredient> <name>Sliced almonds</name> <quantity>1/2</quantity> <unit>cup</unit> </ingredient> <ingredient> <name>Lemon juice</name> <quantity>2</quantity> <unit>tablespoons</unit> </ingredient> <ingredient> <name>Parsley</name> <quantity>2</quantity> <unit>tablespoons</unit> <note>chopped</note> </ingredient> </ingredients> <instructions> <step>Rinse the sole fillets under cold water and pat dry with paper towels.</step> <step>Mix the flour, salt, and pepper in a shallow dish.</step> <step>Lightly dredge the sole fillets in the flour mixture, shaking off any excess.</step> <step>In a large skillet, melt 2 tablespoons of butter over medium heat.</step> <step>Add the fish fillets to the skillet and cook for about 3 minutes on each side, or until golden brown and cooked through.</step> <step>Remove the fillets from the skillet and keep warm on a serving platter.</step> <step>In the same skillet, add the remaining 2 tablespoons of butter and sliced almonds. Cook, stirring frequently, until the almonds are golden brown.</step> <step>Stir in the lemon juice and parsley, and heat through.</step> <step>Pour the almond mixture over the fish fillets.</step> <step>Serve immediately.</step> </instructions> </recipe>

IMPORTANT: If you hold down the Option key when selecting the plug-in from the Automation Menu, the plug-in will present the option to copy the first part of the default ChatGPT recipe prompt to the clipboard for you to paste into the ChatGPT input field later.

Using the Plug-In

To create a recipe task from the results of a ChatGPT request:

Comose field empty and with prompt

Omni Automation Translates Data

The Omni Automation code within the example plug-in parses the returned XML, extracting the relevant information that is then used to create a new task with its necessary components.

The ingredients are placed as a bulleted list in the notes field, and the instruction steps become a sequential list of sub-tasks to the host recipe task. Other optional elements are placed at the bottom of the notes field.

The Plug-In

Here’s the plug-in for creating recipe tasks from ChatGPT prompts returning data in XML format.

IMPORTANT: This plug-in is in development (beta software) so don’t use with your important content. You can help make it better by reporting any issues (and suggestions) along with any non-functioning XML reponses to the mail (comments and suggestions) link on the Privacy webpage for this site.

Generate Recipe Task
  
/*{ "type": "action", "targets": ["omnifocus"], "author": "Otto Automator", "identifier": "com.omni-automation.of.xml-recipe-to-task", "version": "1.2", "description": "This plug-in creates a new recipe task from the XML content in note of the selected item. This XML should be the result of a ChatGPT query written to begin with:\n\n“Provide in XML format, the recipe for…”\n\nNOTE: Hold down the Option key, when selecting this plug-in from the Automation menu, to have the ChatGPT prompt placed on the clipboard.", "label": "Generate Recipe Task", "shortLabel": "Recipe Task", "paletteLabel": "Recipe Task", "image": "list.bullet.rectangle.fill" }*/ (() => { function unCamelCase(aStr){ // from stackoverflow.com // insert a space before all caps newStr = aStr.replace(/([A-Z])/g, ' $1') // uppercase the first character newStr = newStr.replace(/^./, function(str){ return str.toUpperCase(); }) return newStr } const ChatGPTPrompt = "Provide in XML format, a recipe for " const action = new PlugIn.Action(async function(selection, sender){ try { versStr = action.plugIn.version.versionString if(!versStr.includes(".")){versStr = versStr + ".0"} console.log("version string:", versStr) if (app.optionKeyDown){ alert = new Alert("ChatGPT Prompt", "Put the prompt on the clipboard?") alert.addOption("Copy Prompt") alert.addOption("Cancel") buttonIndex = await alert.show() console.log("buttonIndex", buttonIndex) if(buttonIndex === 0){ console.log("copying…") Pasteboard.general.string = ChatGPTPrompt console.log("done.") await new Alert("ChatGPT Prompt", `The prompt:\n\n“${ChatGPTPrompt}”\n\nis on the clipboard.`).show() } throw {name: "-128", message: "User cancelled."} } selectedItem = selection.databaseObjects[0] xmlText = selectedItem.note if(xmlText.length === 0){ throw { name: "Missing Resource", message: "The note of the selected item contains no text in XML format." } } console.log(xmlText) alertTitle = `Recipe Task from XML v${versStr}` alertMessage = "This plug-in creates a new recipe task from the XML content in note of the selected item. This XML should be the result of a ChatGPT query written to begin with:\n\n“Provide in XML format, the recipe for…”" alert = new Alert(alertTitle, alertMessage) alert.addOption("Continue") alert.addOption("Cancel") buttonIndex = await alert.show() if(buttonIndex !== 0){ return "user cancelled" } behavior = new XML.WhitespaceBehavior(XML.WhitespaceBehavior.Type.Ignore) data = Data.fromString(xmlText) xmlDoc = XML.Document.fromData(data, behavior) rootElementName = xmlDoc.rootElement.name console.log("rootElement name:", rootElementName) if (rootElementName !== "recipe"){ throw { name: "XML Issue", message: "The root element is not titled: “recipe”" } } elementNames = xmlDoc.rootElement.children.map(child => child.name) console.log("elementNames", elementNames) if (!elementNames.includes("title")){ if (!elementNames.includes("name")){ throw { name: "XML Issue", message: "The XML does not include an element titled: “title”" } } else {var recipeTitle = "name"} } else {var recipeTitle = "title"} if (!elementNames.includes("ingredients")){ throw { name: "XML Issue", message: "The XML does not include an element titled: “ingredients”" } } if (!elementNames.includes("instructions")){ if (!elementNames.includes("steps")){ throw { name: "XML Issue", message: "The XML does not include an element titled: “instructions” or “steps”" } } else {var instructionsElementTitle = "steps"} } else {var instructionsElementTitle = "instructions"} recipeTitle = xmlDoc.rootElement.firstChildNamed(recipeTitle).stringContents console.log("recipeTitle", recipeTitle) ingredientsElement = xmlDoc.rootElement.firstChildNamed("ingredients") instructionsElement = xmlDoc.rootElement.firstChildNamed(instructionsElementTitle) recipeTask = new Task("Recipe: " + recipeTitle, inbox.beginning) for (element of instructionsElement.children){ taskTitle = element.stringContents new Task(taskTitle, recipeTask.ending) } textBlock = "Ingredients:" for (element of ingredientsElement.children){ if(element.childrenCount > 1){ elementContent = "" for (elementChild of element.children){ elementContent = elementContent + elementChild.stringContents + " " } textBlock = textBlock + ("\n" + "\t•\t" + elementContent) } else { attributeNames = element.attributeNames console.log("element atributes:", element.attributeNames) if (attributeNames.length !== 0){ var prefaceMaterial = "" for (attributeName of attributeNames){ attributeValue = element.attributeNamed(attributeName) if(attributeValue){ prefaceMaterial = prefaceMaterial + attributeValue + " " } } } else {var prefaceMaterial = ""} ingredientTitle = prefaceMaterial + element.stringContents textBlock = textBlock + ("\n" + "\t•\t" + ingredientTitle) } } textBlock = textBlock + "\n" console.log("Processing optional elements…") reservedNames = ["title","name","ingredients","instructions"] for (elementName of elementNames){ console.log("elementName", elementName) if (!reservedNames.includes(elementName)){ optionalElement = xmlDoc.rootElement.firstChildNamed(elementName) console.log("optionalElement", optionalElement) childCount = optionalElement.childrenCount console.log("childCount", childCount) if(childCount === 1){ elementTitle = unCamelCase(elementName) textBlock = textBlock + ("\n" + elementTitle + ": " + optionalElement.stringContents) } else { elementTitle = unCamelCase(elementName) elementTextBlock = elementTitle + ":" for (childElement of optionalElement.children){ childString = childElement.stringContents elementTextBlock = elementTextBlock + ("\n" + "\t•\t" + childString) } textBlock = textBlock + "\n" + elementTextBlock } } } recipeTask.note = textBlock recipeTask.url.open() } catch(err){ if(err.name !== "-128" && !err.causedByUserCancelling){ console.error(err.name, err.message) new Alert(err.name, err.message).show() } } }); action.validate = function(selection, sender){ if(app.optionKeyDown){ return true } else { return ( selection.databaseObjects.length === 1 && selection.projects.length === 1 || selection.tasks.length === 1 )} }; return action; })();