Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/recipe-fiddle.git
<?php
namespace com\whitemagicsoftware;

require_once "constants.php";
require_once "class.BaseController.php";
require_once "class.SecureImageUpload.php";
require_once "class.Scan.php";

/**
 * Provides recipe editing and viewing functionality.
 */
class Recipe extends BaseController {
  private $scannedText = null;
  private $title = null;

  function __construct() {
    parent::__construct();
  }

  /**
   * Checks to see if the given recipe ID exists.
   *
   * @param $id A recipe ID to find.
   * @return true if the given recipe ID has data.
   */
  protected function exists( $id ) {
    return $this->isTrue( $this->call( "is_existing_recipe", "exists", $id ) );
  }

  /**
   * Creates a new recipe in the database.
   *
   * @return 0 if a new recipe could not be created.
   */
  private function create() {
    $result = $this->call( "create_recipe", "id", $this->getAccountId() );
    return isset( $result[0] ) ? $result[0]["id"] : 0;
  }

  /**
   * Deletes the recipe from the user's account. The recipe is still in the
   * system and should probably be removed.
   */
  private function annihilate() {
    $this->call( "delete_recipe", "", $this->getId(), $this->getAccountId() );
  }

  /**
   * Returns the most recently added recipe ID for the given authentication
   * token. This is called by the superclass when the ID could not be
   * determined through POST, GET, or URL. It is the last resort for
   * obtaining a valid ID.
   *
   * @return 0 if no ID can be found.
   */
  protected function getLastResortId() {
    $auth_id = $this->getAuthenticationId();

    $result = $this->call( "get_recent_recipe_id", "id", $auth_id );
    $result = isset( $result[0] ) ? $result[0]["id"] : 0;

    // If no recent recipe can be found, create one. This is required so
    // that new accounts get a recipe.
    if( $result <= 0 ) {
      $result = $this->create();
    }

    return $result;
  }

  /**
   * Returns the name of the parameter used to obtain the identifier for
   * the object to edit.
   *
   * @return "recipe-id"
   */
  protected function getParameterIdName() {
    return "recipe-id";
  }

  /**
   * Returns the recipe title.
   *
   * @return The title of the recipe, or "untitled" if it could not be found.
   */
  private function getTitle() {
    if( $this->title == null ) {
      $result = $this->call( "get_recipe_title", "title", $this->getId() );
      $this->title = $result ? $result[0]["title"] : "untitled";
    }

    return $this->title;
  }

  /**
   * Returns a recipe instruction.
   *
   * @param $seq The unique identifier for the instruction (its sequence
   * number is constrained to be unique).
   */
  private function getInstruction( $seq ) {
    $result = $this->call( "get_recipe_instruction", "instruction",
      $this->getId(),
      $this->getParameterId( "seq", 0 )
    );

    return $result ? $result[0]["instruction"] : "";
  }

  /**
   * Returns the name of the stylesheet to use for transforming the
   * recipe from XML to XHTML.
   *
   * @return "xsl/recipe.xsl"
   */
  protected function getStylesheetName() {
    return "xsl/recipe.xsl";
  }

  protected function getAuthorizationFunctionName() {
    return "is_authorized_recipe";
  }

  /**
   * Returns a recipe in XML format.
   */
  private function getXml() {
    $result = $this->call( "generate_recipe_xml", "x", $this->getId() );
    return isset( $result ) ? $result[0]["x"] : $this->getErrorXml();
  }

  /**
   * Returns a recipe in XHTML format.
   */
  protected function getXhtml() {
    $xslt = $this->getXsltEngine();
    $xslt->setXml( $this->getXml() );
    $xslt->setStylesheet( $this->getStylesheetName() );

    $xslt->setParameter( $this->getParameterIdName(), $this->getId() );
    $xslt->setParameter( "editable", $this->isEditable() );
    $xslt->setParameter( "cookie", $this->getCookieToken() );
    $xslt->setParameter( "ingredient-text", $this->getScannedText() );

    return $xslt->transform();
  }

  /**
   * Sets the value of the scanned text to pass to the stylesheet.
   */
  private function setScannedText( $scannedText ) {
    // The XSLT processor cannot pass both single-quotes and double-quotes.
    // @see https://bugs.php.net/bug.php?id=64137
    $this->scannedText = str_replace( "\"", "''", $scannedText );
  }

  /**
   * Returns the scanned text to pass into the stylesheet.
   *
   * @return A string, possibly null.
   */
  private function getScannedText() {
    return $this->scannedText;
  }

  /**
   * Attaches a photograph to a recipe. This removes any previous photo and
   * sets the photo anew. This also will update the photograph citation (as
   * opposed to the recipe citation).
   */
  private function updatePhotograph() {
    $uploader = new SecureImageUpload();
    $id = $this->getAccountId();
    $appId = "recipe/" . $this->getId();

    // If the photograph uploaded successfully, store the image URL.
    if( $uploader->handle( "photograph-image", $id, $appId ) ) {
      // Get a handle to the file that was uploaded.
      $url = $uploader->getURL();

      // Insert or update the photograph to the given URL.
      $this->call( "recipe_photograph_upsert", "", $this->getId(), $url );
    }

    // The user might have removed the author name or URL.
    $this->call( "recipe_photograph_citation_upsert", "", $this->getId(),
      $this->getParameter( "photograph-author-name" ),
      $this->getParameterURL( "photograph-author-url" )
    );
  }

  /**
   * Associates the original author of a recipe with the recipe being
   * added.
   */
  private function updateCitation() {
    $this->call( "recipe_citation_upsert", "", $this->getId(),
      $this->getParameter( "recipe-author-name" ),
      $this->getParameterURL( "recipe-author-url" ),
      $this->getParameterId( "recipe-attribution-id", 0 )
    );
  }

  /**
   * Parses the text within a scanned image.
   */
  private function scan() {
    $uploader = new SecureImageUpload();
    $id = $this->getAccountId();
    $appId = "scan/" . $this->getId();

    // If the photograph uploaded successfully, 
    if( $uploader->handle( "scan-image", $id, $appId ) ) {
      // Get a handle to the file that was uploaded.
      $filename = $uploader->getFilename();

      // Perform OCR on the text.
      $scanner = new Scan();

      // Guesstiparse the OCR results into ingredients and instructions.
      $this->setScannedText( $scanner->distill( $filename ) );
    }
  }

  /**
   * Inserts an ingredient variation into the database.
   */
  private function insertSubstitute() {
    $original_id = $this->getParameterId( "ingredient-substitute-original" );
    $substitute  = $this->getParameter( "ingredient-substitute-alternative" );
    $ingredient  = json_decode( $this->parseIngredientText( $substitute ) );
    $alternative_id = $this->parseIngredient( $ingredient );

    if( $alternative_id > 0 ) {
      $this->call( "substitute_insert", "",
        $this->getId(), $original_id, $alternative_id );
    }
  }

  private function upsertPreparation( $temperature, $unit ) {
    if( is_numeric( $temperature ) ) {
      $this->call( "preparation_upsert", "",
        $this->getId(), $temperature, $unit );
    }
  }

  private function deletePreparation() {
    $this->call( "preparation_delete", "", $this->getId() );
  }

  private function insertIngredientGroup( $group_name ) {
    $this->call( "ingredient_group_insert", "",
      $this->getId(), $group_name );
  }

  /**
   * Replaces the steps for the given instruction group.
   */
  private function upsertInstructions( $instruction_group_id, $steps ) {
    // Separate each step from the list and eliminate empty lines.
    $steps = $this->toArray( array_filter( explode( "\n", $steps ) ) );
    $this->call( "instructions_insert", "", $instruction_group_id, $steps );
  }

  private function insertTag( $tag ) {
    $this->call( "tag_insert", "", $this->getId(), $tag );
  }

  private function deleteTag( $tag ) {
    $this->call( "tag_delete", "", $this->getId(), $tag );
  }

  private function deleteInstruction( $instruction_id ) {
    $this->call( "instruction_delete", "",
      $this->getId(), $instruction_id );
  }

  private function createInstructionGroup() {
    $this->call( "instruction_group_create", "", $this->getId() );
  }

  private function deleteInstructionGroup( $group_id ) {
    $this->call( "instruction_group_delete", "", $group_id );
  }

  private function moveInstructionGroupAbove( $group_id ) {
    $this->call( "instruction_group_move", "",
      $this->getId(), $group_id, true );
  }

  private function moveInstructionGroupBelow( $group_id ) {
    $this->call( "instruction_group_move", "",
      $this->getId(), $group_id, false );
  }

  /**
   * \todo Allow users to insert equipment without having to open the
   * ingredient/instruction editor.
   */
  private function upsertEquipment() {
  /*
    $this->call( "equipment_upsert", "", $this->getId(),
      $this->getParameterId( "equipment-id" ),
      $this->getParameterId( "equipment-group-id" ),
      $this->getParameter( "equipment-name" ),
      $this->getParameter( "equipment-alias" )
    );
    */
  }

  private function deleteEquipment() {
    $this->call( "equipment_delete", "",
      $this->getParameterId( "delete_equipment" ) );
  }

  private function upsertIngredient() {
    $ingredientName = $this->getParameter( "ingredient-name" );

    if( isset( $ingredientName ) ) {
      $this->call( "ingredient_upsert", "",
        $this->getId( "ingredient-group-id" ),
        $ingredientName,
        $this->getParameter( "ingredient-unit", 0 ),
        $this->getParameter( "ingredient-min", 0 ),
        $this->getParameter( "ingredient-max", 0 ),
        $this->getParameter( "ingredient-alias" ),
        $this->getParameter( "ingredient-condition" ),
        $this->getParameter( "ingredient-optional", 1 ) );
    }
  }

  private function moveIngredient() {
    $ingredient = $this->getParameterId( "ingredient" );
    $group = $this->getParameterId( "group" );
    $index = $this->getParameterId( "index" );

    if( $ingredient > 0 && $group > 0 && $index >= 0 ) {
      $this->call( "ingredient_move", "", $ingredient, $group, $index );
    }
  }

  private function setTitle( $title ) {
    global $DEFAULT_RECIPE_TITLE;
    $result = $this->call( "set_recipe_title", "title",
      $this->getId(), $title );
    return $result ? $result[0]["title"] : $DEFAULT_RECIPE_TITLE;
  }

  private function setInstructionGroupLabel( $id, $old, $new ) {
    global $DEFAULT_INSTRUCTIONS_TITLE;

    $result = $this->call( "set_instruction_group_label", "label",
      $id, $old, $new );
    return $result ? $result[0]['label'] : $DEFAULT_INSTRUCTIONS_TITLE;
  }

  private function setIngredientGroupLabel( $id, $old, $new ) {
    global $DEFAULT_INGREDIENTS_TITLE;

    $result = $this->call( "set_ingredient_group_label", "label",
      $id, $old, $new );
    return $result ? $result[0]['label'] : $DEFAULT_INGREDIENTS_TITLE;
  }

  /**
   * Inserts an instruction into the database.
   */
  private function insertInstruction( $text ) {
    $this->call( "instruction_insert", "", $this->getId(), $text );
  }

  /**
   * Inserts recipe equipment into the database.
   *
   * @param $equipment A noun, perhaps a piece of equipment.
   */
  private function insertEquipment( $equipment ) {
    $this->call( "equipment_insert", "", $this->getId(), $equipment );
  }

  /**
   * Given a line of text, this invokes the Natural Language Parser
   * to return a JSON-encoded result. The decoded JSON results can be passed
   * into the instruction processor.
   *
   * @return A JSON-encoded string.
   */
  public function parseInstructionText( $text ) {
    global $SERVICE_NLP;

    // Punctuate new lines with a period.
    $text = str_replace( PHP_EOL, ". ", $text );

    $curl_parameters = array(
      'q' => $text,
      'nlp' => 's'
    );

    $curl_options = array(
      CURLOPT_URL => $SERVICE_NLP,
      CURLOPT_POST => true,
      CURLOPT_POSTFIELDS => http_build_query( $curl_parameters ),
      CURLOPT_HTTP_VERSION => 1.0,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HEADER => false
    );

    $curl = curl_init();
    curl_setopt_array( $curl, $curl_options );
    $result = curl_exec( $curl );

    // \todo: Delegate to error handler class.
    //$errline = __LINE__ - 1;
    //curl_check_error( $errline, $curl );
    curl_close( $curl );

    return $result;
  }

  /**
   * Interprets the instructions and inserts them into the database.
   */
  private function parseInstructions( $line ) {
    $instructions = json_decode( $line, true );

    foreach( $instructions as $sentence ) {
      $this->parseInstruction( $sentence );
    }
  }

  /**
   * Given a tagged sentence, this interprets the step and puts it into the
   * databsae.
   */
  private function parseInstruction( $sentence ) {
    $step = "";
    $parens = false;
    $temperature = null;

    //$this->log( "Interpret Instruction: " . serialize( $sentence ) );

    foreach( $sentence as $words ) {
      $preheat = false;

      foreach( $words as $word ) {
        $tag = $word["pos"];

        // Ignore periods (sentences should have been detected already).
        // This means ignore the period in "tsp.", for example.
        if( $tag === "Fp" ) {
          continue;
        }
        else if( $tag === "Fpa" ) {
          $parens = true;
        }
        else if( $tag === "Fpt" ) {
          $parens = false;
        }

        if( $parens ) {
          continue;
        }

        // The NLP marks multi-word nouns with underscores. The database has
        // no such markings.
        //
        $form = trim( str_replace( '_', ' ', $word['form'] ) );
        $lemma = trim( str_replace( '_', ' ', $word['lemma'] ) );

        if( strcasecmp( $lemma, 'fuck' ) == 0 ) {
          if( strcasecmp( $form, 'fucker' ) == 0 ) {
            $form = 'thing';
          }
          else if( strcasecmp( $form, 'fuckers' ) == 0 ) {
            $form = 'things';
          }
          else {
            // Don't need such a curse word.
            continue;
          }
        }

        if( strcasecmp( $lemma, 'preheat' ) == 0 ) {
          $preheat = true;
        }

        if( $this->startsWith( $tag, "Z" ) && $preheat ) {
          $temperature = $form;
          continue;
        }

        // Assume a space between words.
        $space = " ";

        // No spaces before punctuation (POS = possessive = 's).
        if( ($step && $this->startsWith( $tag, "F" )) || $tag === "POS" ) {
          $space = "";
        }
        else if( $this->startsWith( $tag, "N" ) ) {
          // If the word is a noun, try to add it to the equipment list.
          $this->insertEquipment( $lemma );
        }

        if( !$preheat ) {
          $step = "$step$space$form";
        }
      }
    }

    if( $step ) {
      // Proper nouns get the possessives split: merge them back.
      $step = str_replace( " 's", "'s", $step );

      $this->insertInstruction( $step );
    }

    if( $temperature && $preheat ) {
      $this->upsertPreparation( $temperature, 'F' );
    }
  }

  /**
   * Calls the Natural Language Processor to decipher the given text.
   * The results from this method can be passed to the ingredient extract
   * methods.
   *
   * @param $text The text to tag.
   *
   * @return A JSON-encoded list of words, tagged as parts of speech.
   */
  public function parseIngredientText( $text ) {
    global $SERVICE_NLP;

    $curl_parameters = array(
      'q' => $text,
      'nlp' => 'p'
    );

    $curl_options = array(
      CURLOPT_URL => $SERVICE_NLP,
      CURLOPT_POST => true,
      CURLOPT_POSTFIELDS => http_build_query( $curl_parameters ),
      CURLOPT_HTTP_VERSION => 1.0,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HEADER => false
    );

    $curl = curl_init();
    curl_setopt_array( $curl, $curl_options );
    $result = curl_exec( $curl );

    // \todo: Delegate to error handler class.
    //curl_check_error( $errline, $curl );
    //$errline = __LINE__ - 1;
    curl_close( $curl );

    return $result;
  }

  /**
   * Calls a natural language processor to split the recipe into distinct
   * parts-of-speech (for ingredients and instructions). This then deciphers
   * the meaning of those items and stores them in the database.
   */
  private function setIngredientRecipeText( $text ) {
    $result = $this->parseIngredientText( $text );

    // Only parse a result from the NLP if a result was obtained.
    if( isset( $result ) ) {
      $line_array = preg_split( '/(\r?\n)/', trim( $result ) );

      //$this->log( serialize( $line_array ) );

      foreach( $line_array as $line ) {
        if( !empty( $line ) ) {
          $decoded = json_decode( $line );

          if( $decoded ) {
            $this->parseIngredient( $decoded );
          }
        }
      }
    }
  }

  /**
   * The list of ingredients was given on a single line. Try to sort the
   * ingredients by detecting nouns. This really only works if the language
   * has recipe ingredients structured in a similar way to English.
   */
  private function parseSplitIngredientText( $decoded ) {
    $ingredient_start = 0;
    $ingredient_end = 0;
    $nouns = 0;
    $prevNoun = false;

    // If there is only one consecutive noun (or adjective), then submit
    // the entire line.
    // \todo: Count consecutive tokens instead of individual tokens...
    foreach( $decoded as $token ) {
      $noun = $this->startsWith( $token->pos, "N" );

      // If the current token is a noun and the previous token was a noun,
      // then this is a consecutive noun, which doesn't count multiple times.
      if( $noun ) {
        if( $prevNoun ) {
          continue;
        }
        else {
          $nouns++;

          // If more than 1 non-conescutive noun was detected, it implies
          // multiple nouns per line. The user probably submitted all the
          // ingredients on a single line.
          if( $nouns > 1 ) {
            break;
          }
        }
      }

      $prevNoun = $noun;
    }

    if( $nouns > 1 ) {
      foreach( $decoded as $token ) {
        $ingredient_end++;

        if( $this->startsWith( $token->pos, "N" ) ) {
          $length = $ingredient_end - $ingredient_start;

          $this->parseIngredient(
            array_slice( $decoded, $ingredient_start, $length ) );
          $ingredient_start = $ingredient_end;
        }
      }
    }
    else if( $nouns == 1 ) {
      $this->parseIngredient( $decoded );
    }
  }

  /**
   * Parses the ingredient text from a decoded JSON document that has been
   * run through the Natural Language Parser, then inserts the ingredient
   * into the database.
   *
   * @param $decoded The ingredient text to parse.
   * @return The ingredient ID that was inserted into the database, or -1
   * on error.
   */
  private function parseIngredient( $decoded ) {
    $ingredient = $this->extractIngredient( $decoded );

    //$this->log( "ingredient: " . serialize($ingredient) );

    return $this->insertIngredient( $ingredient );
  }

  /**
   * Performs an analysis of the line (that has been split into an array of
   * NLP result values) and stores the result in the database.
   *
   * @param $decoded A recipe ingredient.
   */
  public function extractIngredient( $decoded ) {
    $result = array();

    if( isset( $decoded ) && !empty( $decoded ) ) {
      // Parse:
      // - quantities (Z)
      // - measurement unit (Zp)
      // - initial conditions (RB, VBD)
      // - the ingredient (NN, NNS, NP#####)
      list( $min, $max ) = $this->extractIngredientMeasures( $decoded );
      $unit = $this->extractIngredientUnit( $decoded );
      $ingredient = $this->extractIngredientName( $decoded );
      $condition = $this->extractIngredientCondition( $decoded, $ingredient );

      $result = array(
        'name'=>$ingredient,
        'unit'=>$unit,
        'min'=>$min,
        'max'=>$max,
        'condition'=>$condition );
    }

    return $result;
  }

  /**
   * The first noun is the ingredient. This will remove the underscores
   * (if present) that were injected by the NLP.
   *
   * @param $decoded The decoded JSON string as an array.
   * @param $includeAdjectives Whether to include other J*s when considering
   * ingredient name extraction.
   * @param $includeVerbs Whether to include other VB*s when considering
   * ingredient name extraction.
   */
  public function extractIngredientName( $decoded,
      $includeAdjectives = true,
      $includeVerbs = true ) {
    $ingredient = "";
    $matching = false;
    $parens = false;

    foreach( $decoded as $token ) {
      //$this->log( "POS: " . $token->pos . " LEMMA: " . $token->lemma );

      // Ignore parentheticals (they are usually converted measures).
      if( ($token->pos === "Fpa") ||
          ($token->pos === "Fca") || 
          ($token->pos === "Fla") ) {
        $parens = true;
      }
      else
      if( ($token->pos === "Fpt") ||
          ($token->pos === "Fct") ||
          ($token->pos === "Flt" ) ) {
        $parens = false;
      }

      // Ignore the word "a" (which is not usually "one").
      if( $parens || ($token->form === "a") || ($token->lemma === "fuck") ||
          ($token->lemma === "handful") ) {
        continue;
      }

      // Track when a sequence of nouns are found.
      $found = $this->startsWith( $token->pos, "N" );

      // \todo: Only include these if NN* or NP* has not been found?
      // That is, only combine adjectives to name when preceding a noun?
      if( $includeAdjectives ) {
        $found = $found || $this->startsWith( $token->pos, "J" );
      }
      
      if( $includeVerbs ) {
        $found = $found || $this->startsWith( $token->pos, "VB" );

        $verb_pos = array( "VBD", "VBN", "VBP" );

        // Allow words such as "minced" to be parsed as conditions. If
        // these were prepended to the ingredient name, then they would
        // not be parsed. Items such as "slivered almonds" must be treated
        // as unique ingredients.
        if( in_array( $token->pos, $verb_pos ) ) {
          continue;
        }
      }

      // \todo: Note that it is a "heaping" amount.
      if( $token->lemma === "heap" ) {
        continue;
      }

      // Stop looking for ingredients once the first sequence of nouns
      // has been found.
      if( !$found && $matching ) {
        break;
      }

      if( $found ) {
        // Match nouns of NN, NNS, and NP######.
        $ingredient = "$ingredient $token->lemma";

        // Combine consecutive nouns.
        $matching = true;
      }
    }

    if( $ingredient ) {
      // Locutions have underscores; eliminate to match name in database.
      $ingredient = trim( str_replace( "_", " ", $ingredient ) );
    }

    return $ingredient;
  }

  /**
   * Finds the minimum and maximum measurements for the ingredient.
   * \todo: Detect items like "1 16-oz can beans" or "1 500g pork tenderloin".
   */
  public function extractIngredientMeasures( $decoded, $default_min = 1 ) {
    $min = null;
    $max = 0;
    $number = 0;
    $parens = false;
    $matching = false;
    $found = false;

    //$this->log( "Extract Ingredient Measure" );

    // Ensure default values are returned if there are no ingredient
    // measures.
    if( !isset( $decoded ) ) {
      $decoded = array();
    }

    foreach( $decoded as $token ) {
      if( ($token->pos === "Fpa") ||
          ($token->pos === "Fca") || 
          ($token->pos === "Fla") ) {
        $parens = true;
      }
      else
      if( ($token->pos === "Fpt") ||
          ($token->pos === "Fct") ||
          ($token->pos === "Flt" ) ) {
        $parens = false;
      }

      //$this->log( "POS: " . $token->pos . " LEMMA: " . $token->lemma );

      // Ignore parentheticals (they are usually converted measures) and the
      // word "a" (which is not usually "one").
      if( $parens || ($token->form === "a") ) {
        continue;
      }

      if( $token->lemma === "couple" ) {
        $min = 2;
        $max = 0;
        break;
      } else if( $token->lemma === "few" ) {
        $min = 3;
        $max = 4;
        break;
      } else if( $token->lemma === "handful" ) {
        $min = 5;
        $max = 6;
        break;
      } else if( $token->lemma === "several" ) {
        $min = 6;
        $max = 8;
        break;
      }

      // Find consecutive numbers (Fz indicates unicode character).
      $found = $token->pos === "Z" ||
               $token->pos === "Zd" ||
               $token->pos === "Zu" ||
               $token->pos === "Fz";

      if( !$found && $matching ) {
        if( $min == null ) {
          // Save the first value found.
          $min = $number;

          // Continue through the loop once more.
          $matching = false;
          $number = 0;
        }
        else {
          // If the min has been found, then another value is found. Save
          // it and terminate the loop -- no number parsing.
          $max = $number;
          break;
        }
      }
      else if( $found ) {
        if( $matching ) {
          $number += $this->parseNumber( $token->lemma );
        }
        else {
          // If there are two measures per line instance, take the first.
          // Hopefully this works when all the ingredients are on one
          // line...
          $number = $this->parseNumber( $token->lemma );
          $matching = true;
        }
      }
    }

    //$this->log( "MIN: '$min' MAX: '$max' NUM: '$number'"  );

    if( $min == null || $min == 0 ) {
      if( $number ) {
        $min = $number;
      }
      else {
        $min = $default_min;
      }
    }

    if( $max == null ) {
      $max = 0;
    }

    return array( $min, $max );
  }

  /**
   * Extracts the first unit detected and returns it. This will return
   * null if no unit of measurement could be found.
   */
  public function extractIngredientUnit( $decoded ) {
    $unit = null;
    $parens = false;

    //$this->log( "Extract Ingredient Unit" );

    foreach( $decoded as $token ) {
      if( ($token->pos === "Fpa") ||
          ($token->pos === "Fca") || 
          ($token->pos === "Fla") ) {
        $parens = true;
      }
      else
      if( ($token->pos === "Fpt") ||
          ($token->pos === "Fct") ||
          ($token->pos === "Flt" ) ) {
        $parens = false;
      }

      // "to taste" isn't strictly a measurement...
      if( $token->lemma === "taste" ) {
        $unit = "pinch";
        break;
      }

      //$this->log( "POS: " . $token->pos . " LEMMA: " . $token->lemma );

      // Ignore parentheticals 
      if( $parens ) {
        continue;
      }

      // The first Zp is the ingredient unit.
      if( $token->pos === "Zp" ) {
        $unit = "$token->lemma";
        break;
      }
    }

    //$this->log( "Unit: $unit" );

    return $unit;
  }

  /**
   * Extracts all of the verbs that are not part of the ingredient name.
   * This will return null if there are no verbs found. If the ingredient
   * is "sliced water chestnuts" then the "slice" condition will not be
   * returned; similarly, diced tomatoes, chopped walnuts, etc. will not
   * be separated.
   */
  public function extractIngredientCondition( $decoded, $ingredient ) {
    $condition = null;
    $adverb = "";

    // "To taste", "use", "need", are not culinary preparation verbs.
    $blacklist =
    "need paste taste vary use do be prepare want sweet pit hydrogenate flake desire granulate prefer garnish";

    //$this->log( "extractIngredientCondition" );

    foreach( $decoded as $token ) {
      $lemma = $token->lemma;

      if( strpos( $blacklist, $lemma ) !== false ) {
        continue;
      }

      //$this->log( "lemma: " . $lemma );
      //$this->log( "pos  : " . $token->pos );

      // Avoid repeating the descriptive action if the ingredient name
      // already contains said action. Typically the ingredient action
      // is found at the start of the ingredient name.
      if( !strncmp( $ingredient, $lemma, strlen( $lemma ) ) ) {
        continue;
      }

      // Finely, coarsely, and other adverbs to modify the subsequent verb.
      if( $token->pos === "RB" ) {
        $adverb = "$lemma ";
        continue;
      }

      // Chopped, diced, minced, and other verbs.
      if( preg_match( "/^VB[NDP]/", $token->pos ) > 0 ) {
        if( isset( $condition ) ) {
          $condition = "$condition,";
        }

        // Conditions can be modified by adverbs.
        $condition = "$condition$adverb$lemma";
      }

      // If the adverb was used, then don't use it again.
      $adverb = "";
    }

    //$this->log( "condition: $condition" );

    return $condition;
  }

  /**
   * Associates an ingredient with the current recipe.
   *
   * @param $ingredient An array that defines the ingredient.
   * @return The ingredient ID associated with the recipe, or -1 on error.
   */
  private function insertIngredient( $ingredient ) {
    $result = $this->call( "ingredient_insert", "id", $this->getId(),
      $ingredient["name"],
      $ingredient["unit"],
      $ingredient["min"],
      $ingredient["max"],
      $ingredient["condition"] );

    return isset( $result ) ? $result[0]["id"] : -1;
  }

  /**
   * Updates the ingredient lists.
   */
  private function updateIngredients() {
    $ingredientId = $this->getParameterId( "delete_ingredient" );
    $ingredientGroupId = $this->getParameterId( "delete_ingredient_group" );
    $createGroup = $this->getParameterId( "create_ingredient_group" );

    if( $ingredientId > 0 ) {
      $this->call( "ingredient_delete", "", $ingredientId );
    }

    if( $ingredientGroupId > 0 ) {
      $this->call( "ingredient_group_delete", "", $ingredientGroupId );
    }

    if( !empty( $createGroup ) ) {
      $this->call( "ingredient_group_create", "", $this->getId() );
    }
  }

  /**
   * Takes a number and returns its value to three decimal places. This will
   * perform division of a numerator and denomenator (for valid expressions).
   */
  private function parseNumber( $number ) {
    if( $number === "½" ) { $number = "1/2"; } else
    if( $number === "⅓" ) { $number = "1/3"; } else
    if( $number === "⅔" ) { $number = "2/3"; } else
    if( $number === "¼" ) { $number = "1/4"; } else
    if( $number === "¾" ) { $number = "3/4"; } else
    if( $number === "⅕" ) { $number = "1/5"; } else
    if( $number === "⅖" ) { $number = "2/5"; } else
    if( $number === "⅗" ) { $number = "3/5"; } else
    if( $number === "⅘" ) { $number = "4/5"; } else
    if( $number === "⅙" ) { $number = "1/6"; } else
    if( $number === "⅚" ) { $number = "5/6"; } else
    if( $number === "⅛" ) { $number = "1/8"; } else
    if( $number === "⅜" ) { $number = "3/8"; } else
    if( $number === "⅝" ) { $number = "5/8"; } else
    if( $number === "⅞" ) { $number = "7/8"; } 

    $result = 1;
    $number = "$number/";
    list( $numerator, $denomenator ) = explode( "/", $number );

    if( $denomenator > 0 ) {
      $result = (float)$numerator / (float)$denomenator;
    }
    else {
      $result = (float)$number;
    }

    // Database detects numbers to three decimal places.
    return floor( $result * 1000 ) / 1000;
  }

  /**
   * Handles the HTTP request for interacting with a recipe. This
   * method can be simplified to eliminate the if conditionals:
   *
   * <pre>
   * $controller->{ $method.$command }($request);
   * $view->{ $command }();
   * $view->respond();
   * </pre>
   *
   * \todo Execute the command structure using a more elegant design.
   */
  protected function handleRequest() {
    $id = $this->getId();
    $command = $this->getCommand();
    $subcommand = $this->getSubCommand();

    //$this->log( "comamnd $command :: $subcommand" );

    $prg = true;

    // If there is no recipe for the account, then create one.
    if( $command === "create" && $subcommand === "recipe" ) {
      $id = $this->create();

      if( $id != $this->getId() ) {
        global $BASE_RECIPE;

        // Redirect to the newly created recipe.
        if( $this->redirect( $BASE_RECIPE, $id, $this->getTitle() ) ) {
          return;
        }
      }
    }

    if( $command === "delete" && $subcommand === "recipe" ) {
      global $BASE_ACCOUNT;

      $this->annihilate();

      // Redirect to the user's account to avoid creating a new recipe.
      if( $this->redirect( $BASE_ACCOUNT, $this->getAccountId(), "" ) ) {
        // This should always happen...
        return;
      }
    }

    if( $command === "ingredients" ) {
      if( $subcommand === "accept" ) {
        $ingredients = $this->getParameter( "ingredients-list" );

        if( !empty( $ingredients ) ) {
          // Engage the natural language parsing engine.
          $this->setIngredientRecipeText( $ingredients );
        }
      }
      else if( $subcommand === "update" ) {
        $this->updateIngredients();
      }
    }

    // Edit in place values -- save the values entered by the user.
    if( $command === "update" ) {
      $old = $this->getParameter( "original_html" );
      $new = $this->getParameter( "update_value" );

      if( $subcommand === "title" ) {
        echo $this->setTitle( $new );
      }
      elseif( $subcommand === "instruction.group" ) {
        $id = $this->getParameterId( "instruction-group-id" );
        echo $this->setInstructionGroupLabel( $id, $old, $new );
      }
      elseif( $subcommand === "ingredient.group" ) {
        $id = $this->getParameterId( "ingredient-group-id" );
        echo $this->setIngredientGroupLabel( $id, $old, $new );
      }

      // Ensure that the value that was echo'd is the only that is entered
      // into the field. Otherwise the entire HTML document could get
      // embedded.
      return;
    }

    // Ensure at least one hyphen (for the explosion to work).
    if( $command === "substitute" ) {
      if( $subcommand === "accept" ) {
        $this->insertSubstitute();
      }
    }
    elseif( $command === "preparation" ) {
      $temperature = $this->getParameter( "preparation-temperature" );
      $unit = $this->getParameter( "preparation-unit" );

      if( $subcommand === "accept" ) {
        $this->upsertPreparation( $temperature, $unit );
      }
      elseif( $subcommand === "delete" ) {
        $this->deletePreparation();
      }
    }
    elseif( $command === "ingredient" ) {
      if( $subcommand === "accept" ) {
        $this->upsertIngredient();
      }
      else if( $subcommand === "move" ) {
        $this->moveIngredient();
      }
    }
    elseif( $command === "ingredient.group" ) {
      $group_name = $this->getParameter( "ingredient-group-name" );

      if( $subcommand === "accept" ) {
        $this->insertIngredientGroup( $group_name );
      }
    }
    elseif( $command === "instruction" ) {
      $instruction_group_id =
        $this->getParameterId( "instructions_group_id", 1 );
      $steps = $this->getParameter( "steps" );

      if( $subcommand === "accept" ) {
        $this->upsertInstructions( $instruction_group_id, $steps );
      }
    }
    elseif( $command === "instruction.group" ) {
      $group_name = $this->getParameter( "instruction-group-name" );
      $group_id = $this->getParameterId( "instruction_group-id" );

      if( $subcommand === "update" ) {
        $group_id = $this->getParameter( "delete_instruction_group" );

        if( is_numeric( $group_id ) ) {
          $this->deleteInstructionGroup( $group_id );
        }
        else {
          $group_id = $this->getParameter( "create_instruction_group" );

          if( is_numeric( $group_id ) ) {
            $this->createInstructionGroup();
          }
        }
      }
      elseif( $subcommand === "move.above" ) {
        $this->moveInstructionGroupAbove( $group_id );
      }
      elseif( $subcommand === "move.below" ) {
        $this->moveInstructionGroupBelow( $group_id );
      }
    }
    elseif( $command === "equipment" ) {
      if( $subcommand === "accept" ) {
        $this->upsertEquipment();
      }
      elseif( $subcommand === "delete" ) {
        $this->deleteEquipment();
      }
    }
    elseif( $command === "tag" ) {
      $tag = $this->getParameter( "tag" );

      if( $subcommand === "add" ) {
        $this->insertTag( $tag );
      }
      else if( $subcommand === "del" ) {
        $this->deleteTag( $tag );
      }

      // Don't re-render the page when tags are changed.
      return;
    }
    elseif( $command === "photograph" && $subcommand === "accept" ) {
      $this->updatePhotograph();
    }
    elseif( $command === "citation" && $subcommand === "accept" ) {
      $this->updateCitation();
    }
    elseif( $command === "scan" && $subcommand === "accept" ) {
      $this->scan();

      // Do not execute a Post-Redirect-Get when scanning because it will
      // prevent opening the dialog showing the scanned text.
      $prg = false;
    }

    if( $command === "get" ) {
      if( $subcommand === "instruction" ) {
        echo $this->getInstruction( $seq );
      }
    }

    // Do not redirect upon changing title or scanning. All other commands
    // are allowed to attempt a redirection. A redirection happens when the
    // URL is outdated. This prevents the title from being filled with the
    // entire page content.
    //
    if( $subcommand !== "title" &&
        $subcommand !== "xml" &&
        $command !== "scan" ) {
      global $BASE_RECIPE;
      $title = $this->getTitle();

      // Ensure the URL is up-to-date. This cannot be accomplished when
      // performing a partial-page refresh (aka an update command).
      if( $this->redirect( $BASE_RECIPE, $this->getId(), $title ) ) {
        return;
      }
    }

    // Update commands are partial-page refreshes, so only transform when
    // an update command has not been issued.
    if( $command !== "update" && $command !== "get" ) {
      if( $command === "view" && $subcommand === "xml" ) {
        $this->sendHttpHeaders( "application/xml" );
        echo $this->getXml();
      }
      else {
        $this->render( $prg );
      }
    }
  }
}