Freebase Calorie Counter

Will Holcomb

8 August 2013

Freebase is a massive graph-structured database. It is a computer-readable Wikipedia. Reads and writes are done through JSON objects. For this article I explore some of the issues in creating a Freebase-backed recipe builder.

There are several Freebase functions that the app accesses. One of the simplest is the search dropdown that comes up when typing a recipe. This is the search widget. To use it include the following in your page:

<link rel="stylesheet" href="//www.gstatic.com/freebase/suggest/4_2/suggest.min.css" />
<script type="text/javascript" src="//www.gstatic.com/freebase/suggest/4_2/suggest.min.js"></script>

This is a jQuery plugin so you need to have it loaded. The code then to make an input into a suggestion box is:

$('#recipe').suggest( {
  key: API_KEY,
  service_url: SERVICE_URL,
  filter: '(all type:/m/05v2c_w)'
} )

To get an API key log into the Google API console, create an application, and give it access to the Freebase service.

The SERVICE_API tells the plugin which version of Freebase to connect to. In addition to the canonical version, there is a sandbox that gets reloaded once a week. For testing purposes we will be using the sandbox.

The filter tells the plugin which entries to restrict suggestions to. If I go to freebase.com and search for "recipe," I get this page. That is the concept of recipe. I want data that is of a recipe type and that is found under Equivalent Type. That value, "/m/05v2c_w, is what is used for the filter.

When a value is selected a fb-select event is fired. For the recipe that handler needs to query Freebase for the list of ingredients and populate the table with the date.

Freebase is read by constructing a JSON object that has nulls where data should be filled in from the database. The read query for the ingredients is fairly extensive:

$('#recipe').bind( 'fb-select', function( evt, data ) {
  var query = [{
   id: data.id,
   '/food/recipe/dish': {
     id: null,
     name: null
   },
   '/food/recipe/ingredients': [{
     id: null,
     ingredient: {
       id: null,
       name: null,
       '/food/food/energy': null,
       '/common/topic/image': {
         id: null,
         optional: true,
         limit: 1
       },
       optional: true
     },
     unit: {
       id: null,
       name: null,
       optional: true
     },
     quantity: null,
     notes: null
   }],
   '/common/topic/description': null
 }]

At the root I specify the recipe I want by setting the id attribute from the event data. The first block then specifies I want the id and name of the dish that this is a recipe for. The next section is the list of ingredients. Because it is a list, an array is used around the object description.

Each ingredients entry is a CVT. It provides a collection of links to other values. The food energy is used to compute the caloric information.

The image shows two special properties: optional and limit. Limit is the same as SQL; only one entry is returned. Optional specifies that the property doesn't have to be present for an entry to be valid. Without it entries without images would not be returned.

Images are stored on Google's CDN. The url is formed by appending the id to 'https://usercontent.googleapis.com/freebase/v1/image'.

Units is a link to another type, so its name and id are specified whereas quantity is a property of the ingredients entry so null is used.

The instructions for the recipe are simply a text block. It is stored in the description.

The query is executed by sending a JSONP request to Freebase:

function freebase_query( query, handler ) {
  var fb_url = SERVICE_URL + '/mqlread'
  fb_url += "?query=" + encodeURIComponent( JSON.stringify( query ) )
            
  $.getJSON( fb_url, handler )
}

Another important read operation is setting up the units dialog. Rather than hard coding, all of the units and conversion factors are loaded from Freebase. There is a JSON file that specifies the names and Freebase ids of the available units. An array of ids is collected and the query object looks like:

[{
  'id|=': ids,
  id: null,
  '/measurement_unit/mass_unit/weightmass_in_kilograms': null,
  '/measurement_unit/volume_unit/volume_in_cubic_meters': null
}]

This demonstrates one of the postfixes that can be used on an identifier. '|=' takes an array of ids and returns any of them that are found. By specifying a null id, we get the id and then the conversion factor. Both masses and volumes are looked up by the same query and subsequent code checks if the weight or volume is null.

The other half of the application is writing changes to a recipe back to Freebase. Authentication is handled via OAuth. This is done by constructing a URL:

This pops up a login dialog. Once authentication is complete it forwards to the callback url. This must be from the same origin in order for the script to access it. The client id is obtained from the API console.

The returned token must be validated. That is a relatively straightforward process:

var verificationURL = "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token="

function validateToken( token ) {
  $.ajax( {
    url: verificationURL + token,
    data: null,
    success: function( response, responseText ) {  
      if( responseText == 'success' ) {
        saveRecipe()
      }
    },  
    dataType: 'jsonp'
  } )
}

The token is now ready to be used to make MQL queries. A query object is an array of objects. The first step is to create the dish and recipe if they don't already exist:

if( ! $dishModal.dishId || ! $('#recipe').data( 'recipeId') ) {
  query.push( {
    create: 'unless_exists',
    id: null,
    name: $dishModal.dishName,
    type: '/food/recipe/dish'
  } )
  query.push( {
    create: 'unless_exists',
    id: null,
    name: $('#recipe').val(),
    type: '/food/recipe'
  } )
}

Doing the write is much like doing a read. Writes are done via a JSONP request. Unfortunately those requests are limited to HTTP GETs. This means they must be able to fit in a URL which may not be longer than 2000 characters. The write code breaks up the query into appropriate-sized chunks:

 0 ) {
      var test = toSend.slice( 0 )
      test.push( query[0] )

      if( test.length == 1 || encodeURIComponent( JSON.stringify( test ) ).length < 1900 ) {
          toSend = test
          query.shift()
          if( query.length > 0 ) {
              continue
          }
      }

      var freebaseURL = SERVICE_URL + '/mqlwrite'
      freebaseURL += "?oauth_token=" + oauthToken
      freebaseURL += "&query=" + encodeURIComponent( JSON.stringify( toSend ) )
                  
      $.ajax( {
          url: freebaseURL,
          success: handler,
          dataType: 'jsonp'
      } )
      
      toSend = []
  }
}]]>

There are several writes for updating a recipe. The text of the recipe itself is stored in the /common/topic/description property. This property is allowed to have multiple values, so the old value has to be removed if a new one is added:

query.push( {
  id: recipeId,
  '/common/topic/description': {
    connect: 'delete',
    value: origRecipe,
    lang: '/lang/en'
  }
} )

query.push( {
  id: recipeId,
  '/common/topic/description': {
    connect: 'insert',
    value: newRecipe,
    lang: '/lang/en'
  }
} )

The ingredients may either be updated or created depending on whether they were loaded from the initial read:

$.each( rows, function( idx, row ) {
  if( ! row.empty ) {
      if( row.rowId ) {
          query.push( {
              id: recipeId,
              '/food/recipe/ingredients': [{
                  id: row.rowId,
                  quantity: {
                      connect: 'update',
                      value: parseFloat( row.$quantity.val() )
                  },
                  unit: {
                      connect: 'update',
                      id: row.$units.val()
                  },
                  notes: {
                      connect: 'update',
                      value: row.$notes.val(),
                      lang: '/lang/en'
                  }
              }]
          } )
      } else {
          query.push( {
              id: recipeId,
              '/food/recipe/ingredients': {
                  create: 'unless_exists',
                  id: null,
                  quantity: parseFloat( row.$quantity.val() ),
                  unit: {
                      id: row.$units.val()
                  },
                  ingredient: {
                      id: row.ingredientId
                  }
              }
          } )
      }
  }
} )