Help: #howto Build Recipe "database" in Obsidian (complex)

Hah, great thread this. Both entertaining and educating read. Thanks to all involved! :smiley:

1 Like

Also my thanks for these :slight_smile: its a cool idea
@Moonbase59 or @FiekeB can someone of you help me?
i only have two problems at the moment, i copied your zip into a new vault and wanted to try it out, you already have the melon salat recipe in your more or less template. but the recipe dont get shown in both TOCs, i also have installed the dataview addon and enabled inside it the javascript queries and the inline queries. but its still dont work, can you help me? It says following: “Dataview: Query returned 0 results” for reciepes and the TOC for the incredients didnt give any message out its just the title.
FYI im using obsidian 0.12.15

and the second is that i cant find the line.push… code in any dataview script block from your post 40 to change it (even when i didnt understood completly what it should do :smiley:

or did you already have since than even more awesome ideas for the reciepbook inside obsidian and want to share it with the community :slight_smile:

okay, i found out the problem, you have in the zip file no folder with the name “recipe” but you search in both TOC searches inside the folder “recipe” AND for a tag. but the folder eg for the food reciepes is “food” and not reciepe :smiley:

the second of my question: i seen that, in the message 19 from Moonbase59 (where the zip is attached) he have written in the recipe template

list.push(amount * dv.current().portions + " " + unit + " " + link

and in the zip is standing

dv.paragraph("- " + amount * dv.current().portions + " " + unit + " " + link

but i still dont understand the difference between them :frowning:

ADVANCED
Hi all
I have fiddled a little at a recipe repository and have gotten inspired by this thread (and the recipe app i am otherwise using and will delete once my recipes are all imported into Obsidian); so thanks @FiekeB for the idea and @Moonbase59 for some of the implementation ideas/considerations.
I just wanted to lay out the solution that i have implemented as a thank you and hopefully this can be helpful. The solution, however, require a basic understanding of JavaScript. I’ll try to be as explicit as possible tho.
Hopefully this can be helpful for some users - and can be declined for other types of library management (books, movies, games…)!

Requirements
dataview/customJS/MetaEdit/Buttons

Structure

  • 1 note per recipe
  • 1 (or several) aggregation note
    (no ingredient note…)

For my own purpose, I have tried to be as flexible as possible to accommodate different recipe writing styles and sources.
I will break down this post in different posts: the recipe page; the aggregation page; backend

2 Likes

Recipe note

Frontmatter:

---
ServingSize:
cssclass: recipeTable
Alias: []
Tag: []
Date: 2021-09-21
DocType: "Recipe"
CollapseMetaTable: Yes
Meta:
 IsFavourite:
 Rating:
Recipe:
 Courses:
 Categories:
 Collections:
 Source:
 PreparationTime: 
 CookingTime:
 OServingSize:
Ingredients:
 - 
---

I think that the headers are quite self explanatory. I have 2 fields ServingSize and OServingSize: OServingSize represents the original number of portions that the recipe is based on; ServingSize is a variable to adjust ingredient amounts for own purpose. It allows not to have to manually adjust ingredient amounts when importing a recipe.
It is important to keep ServingSize at the top of the FrontMatter but i’ll explain below.

In the ingredient section, input follows this nomenclature: <amount> <unit> <free text for ingredient>, enabling to have more than a word describing an ingredient.

Edit Serving Size
I have created a button using the Buttons & MetaEdit plugins in order to easily update the FrontMatter, which mostly serves to update the ServingSize for ease of manipulation and not have to go back into edit the actual note each time one would like to change the portion size of their dish.
By keeping the field ‘ServingSize’ at the top of the frontmatter, you do not have to scroll down the list of fields when clicking the button, and have an immediate access to this field! It is a question of personal taste, tho!

/```button
name Edit Recipe parameters
type command
action MetaEdit: Run MetaEdit
id EditMetaData
/```

Generic information section
I have a generic information section populated from the ‘Recipe’ part of the FrontMatter:

| |
|-|-
**Courses**: | `$=dv.current().Recipe.Courses`
**Categories**: | `$=dv.current().Recipe.Categories`
**Collections**: | `$=dv.current().Recipe.Collections`
**Serving size**: | `$=dv.current().ServingSize`
**Cooking time**: | `$=dv.current().Recipe.CookingTime` min

which looks like the below:

Ingredients section
In this section, I have used a JS script, using dv.view() from dataview that allows to write 1 code snippet only and reuse it in different notes/sections. On each page, i only reference the code which is easier to maintain. The actual code snippet is going to be shown in the ‘Backend’ section.

On the note, the code looks like:

/```dataviewjs
dv.view("path/to/dv-views/query_ingredient", {ingredients: dv.current().Ingredients, originalportioncount: dv.current().Recipe.OServingSize})
/```

which renders:

Instructions/Direction section
Manual update for each recipe

Backend for the recipe note

In order to use dv.view() from dataview, you need to set up a folder from within Obsidian in which to store these JS snippet and have a JS editor (ex: Textatic for iOS) in order to generate and write into those files.

Once you have created the js file in the JS editor (query_ingredient in my case), you can then copy/paste the below:

let {ingredients, originalportioncount} = input;
let ing = [];

if (!Boolean(originalportioncount)) {
	return "⚠️ <b>Warning</b>\nYou have not specified the original serving size!\n<b>Ingredient amounts cannot be estimated.</b>"
}

for (let i = 0; i < ingredients.length; i++) {
let name = ingredients[i].replace(ingredients[i].split(" ")[0] + " " + ingredients[i].split(" ")[1], "");
let unit = ingredients[i].split(" ")[1];
let amount = ingredients[i].split(" ")[0];
ing[i] = "🥣 " + (amount / originalportioncount * dv.current().ServingSize) + " " + unit + " <b>" + name + "</b>"
}
dv.list(ing)

Note that the code reference 2 arguments: the Ingredient list and the OServingSize FrontMatter filed to rebase the ingredients amounts.

4 Likes

Aggregation view

Given the fact that a recipe repository can become quite big over time, I have written a code snippet that allows for searching a recipe based on different criteria in the frontmatter (on top of normal/static views that can be generated through a normal dataview block) rather than browse through hundreds of files. I’ll try to detail all steps of this search function that can be adapted to other types of libraries.

Note frontmatter
In the note frontmatter, i have placed a series of query terms:

---
QueryCourse:
QueryCategory:
QueryCuisine:
QueryIngredient:
QueryTheme:
QueryFavourite:
QueryRating:
---

One can search recipes by:

  • course
  • Category
  • Collection/cuisine type
  • ingredient
  • theme (tag)
  • whether the recipe is marked favourite (true/false argument)
  • rating

Each term can accept one or several values but for the time being, it performs only a match (no greater/lower than…)!

I have then placed a button that allows to edit those parameters without going into the frontmatter:

/```button
name Search Library
type command
action MetaEdit: Run MetaEdit
id EditMetaData
/```

A textbox that allows me to see what query terms i have entered:

/```dataviewjs
dv.view("/path/to/dv-views/print_data", {toprint: [dv.current().QueryCourse, dv.current().QueryCategory, dv.current().QueryCuisine, dv.current().QueryIngredient, dv.current().QueryTheme, dv.current().QueryFavourite, dv.current().QueryRating,
dv.current().QueryAddedDate]})
/```

The textbox calls a JS snippet called ‘print_data’ that i will detail in the backend section thereafter.

And the actual search query:

/```dataviewjs
dv.view("/path/to/dv-views/query_recipe", {course: dv.current().QueryCourse, dateadded: dv.current().QueryAddedDate, category: dv.current().QueryCategory, cuisine: dv.current().QueryCuisine, ingredient: dv.current().QueryIngredient, theme: dv.current().QueryTheme, isfavourite: dv.current().QueryFavourite, rating: dv.current().QueryRating})
/```

This section calls a JS snippet called ‘query_recipe’ that i will detail in the backend section thereafter.

which all renders like the below:

Backend section

Print_data

This is a JS snippet file to be generated (or pasted directly into the note):

let {toprint} = input;

function BuildList(arg1, construct) {
		
		let ilength = arg1.length
		let TempS = ""
	
		for (let i = 0; i < ilength; i++) {
			if (Boolean(arg1[i])) {
				if (TempS == "") {
					TempS = arg1[i]
				} else {
					TempS = TempS + construct + arg1[i]
				}
			}
		}
		return TempS
	}

dv.el('b', "🔎 search terms: \n• " + BuildList(toprint, "\n• "));

Query_recipe

This particular section requires the CustomJS plugin. There are two parts to this: the query called from the note and the engine solving the search.
The query called from the note is to be placed in a stand-alone JS file (called query_recipe in my case):

const {recipeFunc} = customJS
const DataType = 'Recipe'
let {course, category, cuisine, ingredient, dateadded, theme, isfavourite, rating} = input;
const iArray = [course, category, cuisine, ingredient, dateadded, theme, isfavourite, rating];
const dArray = ["course", "category", "cuisine", "ingredient", "dateadded", "theme", "isfavourite", "rating"];

return recipeFunc.getTable(dv, DataType, dArray, iArray, 0)

Note that the code references ‘CustomJS’ as mentioned above, to solve the query and print a table with the results.
CustomJS needs to be set but all is explained clearly on the plugin page.

To generate the CustomJS code, just create a new JS file with the below code and the search function should work off the cuff.

class recipeFunc {
	DataCheck(arg1, arg2) {
		
		var iarg1 = arg1
		if (moment(iarg1).isValid()) {iarg1 = arg1.toString()}
		var iarg2 = arg2
		if (moment(iarg2).isValid()) {iarg2 = arg2.toString()}
		
		if (!Array.isArray(iarg2) && !Array.isArray(iarg1)) {
			var resultdc = iarg1.search(new RegExp(iarg2, "i")) > -1
		} else if (!Array.isArray(iarg1)) {
			let tempresult = false
			for (let i = 0; i < iarg2.length; i++) {
				tempresult = tempresult || iarg1.search(new RegExp(iarg2[i], "i")) > -1
			}
			var resultdc = tempresult
		} else if (!Array.isArray(iarg2)) {
			let tempresult = false
			for (let i = 0; i < iarg1.length; i++) {
				tempresult = tempresult || iarg1[i].search(new RegExp(iarg2, "i")) > -1
			}
			var resultdc = tempresult
		} else {
			let tempresult = false
			for (let i = 0; i < arg2.length; i++) {
				for (let j = 0; j < arg1.length; j++) {
					tempresult = tempresult || iarg1[j].search(new RegExp(iarg2[i], "i")) > -1
				}
			}
			
			var resultdc = tempresult
		}
		
		return resultdc
	}
	
	Get1stArg(arg3) {
		if (!Array.isArray(arg3)) {
			return arg3
		} else {
			return arg3[0]
		}
	}
	
	topLevelFilter(pobj, DocType, subType) {
		let result = true
		let folderExcl = (DocType == 'Source') ? '00.01' : '00.';
		
		result = !pobj.file.path.contains(folderExcl) && this.GetPoint(pobj, "main", "type") !== undefined && this.GetPoint(pobj, "main", "type") !== null && this.GetPoint(pobj, "main", "type").contains(DocType)
		
		return result
		
	}
	
	IsInSearch(pobj, DocType, darray, iarray) {
	
		let ilength = iarray.length;
		let result = true
	
		for (let i = 0; i < iarray.length; i++) {
			
			if (iarray[i] == undefined || darray[i] == undefined) {result = result && true} else {
					if (Boolean(iarray[i])) {
						let pProp = this.GetpProp(pobj, DocType, darray[i])
						if (!Boolean(pProp)) { result = result && false } else {
							result = result && this.DataCheck(pProp, iarray[i])
						}
					} else {result = result && true}
			}
		}
		return result
	}
	
	getTable(dv, DocType, darray, iarray, tabletype) {
		
//		let tablet = (darray.contains('tabletype')) ? iarray[0] : 0;
		
		let page = dv.pages()
		.filter(p => p && this.topLevelFilter(p, DocType, 0))
		.where(p => p && this.IsInSearch(p, DocType, darray, iarray))

		if (page.length === 0) {
			return this.EmptyQueryMessage()
		}

	dv.table(this.GetTableHeaders(DocType, tabletype), page
			.sort(p => p.file.name, `asc`)
			.map(p => this.GetTableMap(DocType, tabletype,p))); 
	}
	
	EmptyQueryMessage() {
		return dv.el('b', '⚠️ Warning:\nNo result matching your query!')
	}
	
	GetTableHeaders(DataT, TableT) {
		
		let TempData = ["Name"]
		
		switch(DataT) {
			
			case 'Recipe':
			
				TempData = ["Name", "Main ingredients", "Cuisine", "Cooking time", "Rating (1-5)"]
			
			break;
			
		}
	
		return TempData
	
	}
	
	GetTableMap(DataT, TableT, p) {
	
		let TempData = [p.file.link]
	
		switch(DataT) {
			
			case 'Recipe':
			
				TempData = [p.file.link, this.GetPoint(p, DataT, "category"), this.GetPoint(p, DataT, "collection"), this.GetPoint(p, DataT, "cooking") + " min", this.GetPoint(p, DataT, "rating")]
			
			break;
		
		}
	
		return TempData
		
	}
	
	GetpProp(pobj, DocType, dPoint) {

		let result = null

		switch(dPoint) {
			case 'dateadded':
				result = this.GetPoint(pobj, "main", "date")
			break;
			case 'theme':
				result = this.GetPoint(pobj, "main", "tag")
			break;
			case 'course':
				result = this.GetPoint(pobj, DocType, "course")
			break;
			case 'category':
				result = this.GetPoint(pobj, DocType, "category")
			break;
			case 'cuisine':
				result = this.GetPoint(pobj, DocType, "collection")
			break;
			case 'ingredient':
				result = this.GetPoint(pobj, DocType, "ingredient")
			break;
			case 'isfavourite':
				result = this.GetPoint(pobj, DocType, "isfavourite")
			break;
			case 'rating':
				result = this.GetPoint(pobj, DocType, "rating")
			break;
		}
		return result
	}
	
	GetPoint(pobj, fstline, dPoint) {
	
		let result = null
	
		switch(fstline) {
		
			case 'main':
			
				switch(dPoint) {
					case 'alias':
						result = pobj.Alias
					break;
					case 'tag':
						result = pobj.Tag
					break;
					case 'date':
						result = pobj.Date
					break;
					case 'type':
						result = pobj.DocType
					break;
					case 'meatatable':
						result = pobj.CollapseMetaTable
					break;
				}
			
			break;
			
			case 'Recipe':
				
			switch(dPoint) {
				case 'course':
					result = pobj.Recipe.Courses
				break;
				case 'category':
					result = pobj.Recipe.Categories
				break;			
				case 'collection':
					result = pobj.Recipe.Collections
				break;
				case 'source':
					result = pobj.Recipe.Source
				break;
				case 'preparation':
					result = pobj.Recipe.PreparationTime
				break;
				case 'cooking':
					result = pobj.Recipe.CookingTime
				break;
				case 'ingredient':
					result = pobj.Ingredients
				break;
				case 'isfavourite':
					result = pobj.Meta.IsFavourite
				break;
				case 'rating':
					result = pobj.Meta.Rating
				break;
			}
			
			break;

		}
		return result
	}
}

5 Likes

Nice thread, I have a bunch (650 or so) of recipes in Paprika and some interest in getting Obsidian to work as a recipe manager. Paprika is a very decent app, I have to say, but I don’t like being locked in as others have echoed.

a) sync or import from Paprika app or files. The import should be relatively trivial as the data is already quite well organised into JSON format. The export file is simply a zipped up JSON. The chap who wrote Tandoor has a bunch of Python scripts written for importing recipes into his DB, I’d assume that’s a lot of the work done already to get something working. Whether that would be a plugin or Python standalone script I don’t really know.
b) web clipping of recipes. I haven’t looked into it much, but there is an open standard (schema.org/recipe) for recipes seemingly on the web. Have a look at the source code to this Food Wishes recipe for example, it’s all JSON data, quite ripe for converting into YAML data I’d have thought.

Have a big project I’m finishing up at the moment but I’ll come back to this at some point, and keep an eye on this thread.

1 Like

I noticed in Obsidian Roundup about a conversation in Reddit on CookLang , which seems to be like an ideal solution. Do we know if there is any Obsidian implementation?

2 Likes

FYI folks, I’m writing a Python script to unzip the .paprikarecipes files and write the contents to a markdown template.

My goals are:

  1. Move my recipes into Obsidian complete with tags, photos and the ability to scale them.
  2. Build a bookmarklet to scrape recipes from websites into markdown frontmatter - and load them into template files.

I’m 80% of the way through option 1, and will be happy to make the script public when I’m done.

The script processes the images in the paprika json and saves them to a specified folder.

I’ve managed to figure out how to display the images and the recipe steps using dataviewjs (thanks to @Moonbase59’s code here).

What I’m missing is the method of processing the ingredients. It’ll likely be the following format:

ingredients:
  - [1, tsp, salt]
  - [2, grams, pepper]

This will make it easy enough to process servings - you just make the ingredient quantity the size of a single portion, and the display quantity set to servings * quantity.

I’m looking into being able to extract the data in the format above from the paprika files. Currently I’ve saved each line in a list. There’s a few Python libraries about that kind of do this, I may end up just having a raw data column in the ingredient if the parsing doesn’t work very well for manual processing. This unfortunately means that migrating recipes would be a one time action. There is a decent natural language processor API but there’s a limit of 30 queries/day. It is on GitHub, but not so simple to set up for regular folks to clone a GitHub repo and get it running. There is a project here that looks promising.

I used a dictionary to convert some of the categories into nested tags in Obsidian. Paprika just outputs a single tag. That was a bit of a pain as I’ve got a lot of them, but it’s a one time thing for anybody who wants to set it up.

---
name: Yakisoba Noodles
source: Takashi's Noodles
ingredients: ['2 (3-ounce) pieces dried ramen noodles', '2 tablespoons finely shaved katsuobushi (dried bonito flakes)']
source: Takashi's Noodles
difficulty: 
photo_thumbnail: _resources/F0E6DF32-B823-4CC5-852F-F817CF17D15E.jpg
image_url: None
total_time: 
notes: []
nutritional_info: 
description: Yakisoba is an extremely popular casual dish in Japan, especially with kids. During the country’s annual summer festivals you can always find yakisoba
rating: 0
prep_time: 
created: 2020-10-25 18:12:49
directions: ['Place a large pot of water over high heat and bring to a boil. Add the noodles and cook, following package instructions. Rinse under cold running water. Once chilled, drain well and set aside.']
categories: ['Japanese', 'Noodles', 'Ramen']
source_url: 
cook_time: 
servings: [4]
tags: 
  - recipes/world/asian/japanese
  - recipes/noodles
  - recipes/soup/ramen
photos: 
  - _resources/F0E6DF32-B823-4CC5-852F-F817CF17D15E.jpg
  - _resources/F0E6DF32-B823-4CC5-852F-F817CF17D199.jpg
---
3 Likes

As promised, results posted in the Showcase section, GitHub repo.

With this script, you should be able to migrate your Paprika recipes to Obsidian Markdown, and it’s the building blocks to get started on using Obsidian as a recipe manager too. I’ve included a demo vault in the GitHub repo, so you can check it out without any Paprika data.

3 Likes

I can’t manage to query my recipes by category - can anybody help?

This is how it looks in my frontmatter:

title: ["xyz"]
category: ["category1", "category2"]

When I do a dataview query like this:

LIST
FROM "folder"
WHERE category="category1"

nothing can be found.

If, however, I change the respective line in my frontmatter from

category: ["category1", "category2"]

to

category: category1

it works.

How can I define multiple categories in my frontmatter that can be read and queried by dataview? I couldn’t figure it out yet… :frowning:

@alltagsverstand - Change the where line to:
WHERE contains(category,"category1")

In short, WHERE category="category1" means the category field needs to be exactly “category1” and ONLY “category1” for it to match.
contains will look to see if that specific string present at all.

1 Like

@Erisred Howdy! That works! Thanks a lot!

1 Like

Thank you so much for all you offered in this thread. I implemented our family recipes using this model and everyone is loving it! I wanted to ask if you happen to have tinkered with organizing shopping lists based on recipes; basically, fetching quantities for ingredients on a given list of recipes and generating a table with total numbers of ingredients. If you did, I’d be grateful if you can share. Thanks!

I am a newcomer to obsidian and working on a few cookbooks simultaneously. I am using word office driven database, and publishing on the web using WP recipe maker by bootstrapped.ventures.

WP recipe maker is a powerful recipe-making system that is also json driven. I think someone who understands the ins and outs of obsidian should contact Brecht Vandersmissen at bootstrapped.ventures, to help with the integration. This will potentially make obsidian accessible to all the food writers out there

2 Likes

This might be a non-issue for some users, depending on your use case, but I’m concerned that a system that relies so heavily on DataViewJS may negate a lot of the “future-proofing” of Obsidian. Because a lot of the formatting is scripted rather than built into the note, it may not carry over well to changing environments.

Although it doesn’t carry over some of the features – like the ability to scale a recipe – that the scripted version does, I’m looking to build my recipe vault using more basic Obsidian features.

My plan is to include a folder that’s “Ingredients,” where each ingredient can have its own note. This will enable ingredients to be linked, using Obsidian’s ordinary linking system. And to use tags to indicate things like special diet notes (gluten-free, low glycemic index, etc.). Combine these with the use of subfolders to put recipes into major categories (main dish, dessert, etc.) and there should be a lot of flexibility for cross-searching and for creating “table of contents” notes that output based on various search criteria.

3 Likes

Love the ingredients by category TOC! Is it possible to do this without tags? I was hoping to use ‘type: recipe’ and ‘type:ingredient’ and ‘type:moc’ in my YAML instead of tags. Instead of dv.pages("#ingredient and -#toc") how would you say ‘give me pages with type = ingredient’?

Nevermind, I figured it out! For anyone else wanting to do this, replace:
for (let entry of dv.pages('"Ingredients" and #ingredient and -#toc')
with:
for (let entry of dv.pages('"Ingredients"').where(p => p.type == "ingredient")

2 Likes

Does using cooking amounts using something like 3/4 cup or 1/2 tsp mess up the js table?
Here is my list of ingredients
Ingredients:

  • 3/4 cup milk
  • 1 egg
  • 1 cup shredded cheddar cheese
  • 1/2 cup quick cooking oats
  • 1/2 cup plain breadcrumbs
  • 1/2 cup chopped onion
  • 1 teaspoon salt
  • 1 pound lean ground beef
  • 2/3 cup ketchup
  • 1/2 cup packed light brown sugar
  • 1 1/2 teaspoons prepared yellow mustard
  • 1 large onion

And this is what I am getting

  • NaN undefined 3

  • NaN 1

  • NaN 1

  • NaN undefined 1

  • NaN undefined 1

  • NaN undefined 1

  • NaN 1

  • NaN 1

  • NaN undefined 2

  • NaN undefined 1

  • NaN 1

  • NaN 1

Fractions are not interpreted as numerals, unfortunately. If you write 1/2 as 0.5 (etc…) it should work fine for you