Drupal Store Locator - ~~Part 3~~ SAGA

2015-FEB-08:

So now let's configure the importer to automatically Geocode the addresses as they're imported.

Geocoding

Geocoding isn't the process of obtaining latitude and longitude for a physical address like "55 Second Street, San Francisco, CA 94105". This can be done a number of ways, but the easiest way to do this on import is to enable the Geocoding API for the Location module in Drupal.

Navigate to Administration » Configuration » Content authoring

WHITESCREEN

So while writing this article, I did the previous step and got a white screen. After investigation I figured out the location module's geocoding currently has an outstanding issue preventing it from correctly geocoding. YAY!

Basically, Drupal tries to obtain some data dynamically from a resource on google spreadsheets that is no longer available. Setting aside the wisdom of relying on an external resource like this, we'll just say that we need to completely move this solution off the Location module and move it to Geocoder/Geofield/Addressfield/GeoPHP.

Sigh. Lesson learned: Don't make your module dependant on external data sources unless its stated up front. Scrape your shoe in the grass and move on.

So the modules you need to download and install are Geocoder, Geofield, Addressfield, and GeoPHP. Download and enable.

Add an Addressfield to our content object "Retail Partner". The defaults should be sufficient. Make sure you enable a country-specific address form under "form handlers".

After that, we're going to create a single geo field to hold the geo information. There's an option on the geofield config to get the geofield's data by geocoding the addressfield you just created.

So now there's just one problem: Google has strict limits on how much you can geocode in any given day. In order to get past those limits, you have to enter a credit card and have google charge your card for overages. Its been my experience that that never amounts to much until you're geocoding a LOT of points (20k+). I have about 1500 data points to geocode, which importing the list once will put me at or near the usage limits for the day, so I go ahead and get the google account set up to be charged. But that means the geocoding action needs to include my personal API key so google can recognize its me and allow me past the limits.

Currently the shipping version of the Geocoder module does not do that. I found an issue to add the Google API key to geocoding requests and a patch that will be included in a future version of the module. I used git to clone the development version of the module and then patched it and voila! Google geocoding with an API Key.

Now lets expose the the two new fields to the programmable API using the RESTful project:

/**
 * @file
 * Contains RestfulExampleArticlesResource.
 */
class AnkiRestfulPartnerResource extends RestfulEntityBaseNode {

  var $distanceQuery;

  /**
   * Overrides RestfulExampleArticlesResource::publicFieldsInfo().
   */
  public function publicFieldsInfo() {
    $public_fields = parent::publicFieldsInfo();

    $public_fields['nid'] = array(
      'property' => 'nid',
    );
    $public_fields['vid'] = array(
      'property' => 'vid',
    );
    $public_fields['status'] = array(
      'property' => 'status',
    );
    $public_fields['uuid'] = array(
      'property' => 'uuid',
    );
    $public_fields['language'] = array(
      'property' => 'language',
    );
    $public_fields['address'] = array(
      'property' => 'field_addressfield'
    );
    $public_fields['geo'] = array(
      'property' => 'field_geo'
    );

    return $public_fields;
  }

  public function getList() {
    $request = $this->getRequest();
    $autocomplete_options = $this->getPluginKey('autocomplete');
    if (!empty($autocomplete_options['enable']) && isset($request['autocomplete']['string'])) {
      // Return autocomplete list.
      return $this->getListForAutocomplete();
    }
    $entity_type = $this->entityType;
    if (array_key_exists("lat", $request) || array_key_exists("zip", $request)) {
      return $this->proximityQuery($request);
    }
    else {
      $result = $this
        ->getQueryForList()
        ->execute();
    }


    if (empty($result[$entity_type])) {
      return array();
    }

    $ids = array_keys($result[$entity_type]);

    // Pre-load all entities if there is no render cache.
    $cache_info = $this->getPluginKey('render_cache');
    if (!$cache_info['render']) {
      entity_load($entity_type, $ids);
    }

    $return = array();

    foreach ($ids as $id) {
      $toReturn = $this->viewEntity($id);
      $toReturn['location'] = array_merge($toReturn['addressfield'], $toReturn['geo']);
      $return[] = $toReturn;
    }

    return $return;
  }

  /**
   * Overrides RestfulEntityBase::getQueryForList().
   *
   * Expose only published nodes.
   */
  public function getQueryForList() {
    $entity_type = $this->getEntityType();
    $entity_info = entity_get_info($entity_type);
    $query = new EntityFieldQuery();
    $query->entityCondition('entity_type', $this->getEntityType());

    if ($this->bundle && $entity_info['entity keys']['bundle']) {
      $query->entityCondition('bundle', $this->getBundle());
    }
    if ($path = $this->getPath()) {
      $ids = explode(',', $path);
      if (!empty($ids)) {
        $query->entityCondition('entity_id', $ids, 'IN');
      }
    }


    $this->queryForListSort($query);
    $this->queryForListFilter($query);
    $this->queryForListPagination($query);
    $this->addExtraInfoToQuery($query);

    $query->propertyCondition('status', NODE_PUBLISHED);

    return $query;
  }

  public function proximityQuery($origin = NULL) {
    if ($origin == NULL) {
      return [];
    }

    $max_age = variable_get("page_cache_maximum_age", 0);

    if ($max_age !== 0) {
      drupal_add_http_header("Expires", gmdate(DATE_RFC1123, strtotime("+ ".$max_age." seconds")));
      drupal_add_http_header("Cache-Control", "public, max-age=".$max_age);
    }

    $toReturn = ["origin" => []];
    if (array_key_exists("zip", $origin)) {
      $origin += (array) $this->geocodeZip($origin['zip']);
      $toReturn['origin']['zip'] = $origin['zip'];
      //drupal_add_http_header("Expires", date(DATE_RFC850, strtotime("+2 weeks")));
    }
    if ($this->validateOrigin($origin) === true) {
      $toReturn['origin'] += [
        "coords" => [
          "latitude" => $origin['lat'],
          "longitude" => $origin['lon'],
        ]
      ];
    } else {
      return [];
    }

    if (array_key_exists("limit", $_REQUEST) && $_REQUEST['limit'] <= 20) {
      $limit = intval($_REQUEST['limit']);
    } else {
      $limit = variable_get("anki_partner_result_query_limit", 20);
    }


    //variable_set("location_default_distance_unit", "miles");
    $distance_unit = variable_get("location_default_distance_unit", "km");

    $query = db_select("field_data_field_geo", "geo");
    $query->join("node", "n", "n.nid = geo.entity_id and n.vid = geo.revision_id and n.type = geo.bundle and n.status = 1");
    $query->fields("geo", array("field_geo_lon", "field_geo_lat", "entity_id", "revision_id"));
    $query->addExpression($this->earth_distance_sql((float) $origin['lon'], (float) $origin['lat']), "distance");
    $query->condition("geo.entity_type", $this->getEntityType());
    $query->condition("geo.bundle", $this->getBundle());
    $query->condition("field_geo_lon", 0, "!=");
    $query->condition("field_geo_lat", 0, "!=");
    $query->orderBy("distance");
    $query->range(0, $limit);
    $results = $query->execute();


    if ($results->rowCount()) {
      while ($result = $results->fetchObject()) {
        $node = $this->viewEntity($result->entity_id);
        $node += [
          'location' => array_merge($node['address'], $node['geo']),
          "raw_distance" => $result->distance,
          'scalar' => round($result->distance / (($distance_unit == 'km') ? 1000.0 : 1609.347), 1),
          'distance_unit' => $distance_unit
        ];
        $toReturn[] = $node;
      }
    }


    return $toReturn;
  }


  /*
   * Returns the SQL fragment needed to add a column called 'distance'
   * to a query that includes the location table
   *
   * @param $longitude   The measurement point
   * @param $latibude    The measurement point
   * @param $tbl_alias   If necessary, the alias name of the location table to work from.  Only required when working with named {location} tables
   */
  function earth_distance_sql($longitude, $latitude, $tbl_alias = '') {
    //TODO: remove hardcoded location module dependency
    if (!function_exists('earth_radius')) {
      require_once(DRUPAL_ROOT."/sites/all/modules/contrib/location/earth.inc");
    }
    // Make a SQL expression that estimates the distance to the given location.
    $long = deg2rad($longitude);
    $lat = deg2rad($latitude);
    $radius = earth_radius($latitude);

    // If the table alias is specified, add on the separator.
    $tbl_alias = empty($tbl_alias) ? $tbl_alias : ($tbl_alias . '.');

    $coslong = cos($long);
    $coslat = cos($lat);
    $sinlong = sin($long);
    $sinlat = sin($lat);
    return "(IFNULL(ACOS($coslat*COS(RADIANS({$tbl_alias}field_geo_lat))*($coslong*COS(RADIANS({$tbl_alias}field_geo_lon)) + $sinlong*SIN(RADIANS({$tbl_alias}field_geo_lon))) + $sinlat*SIN(RADIANS({$tbl_alias}field_geo_lat))), 0.00000)*$radius)";
  }

  static function geocodeZip($zip) {
    module_load_include("module", "gmap", "gmap");
    module_load_include("inc", "location", "geocoding/google");
    return (array) google_geocode_location(["postal_code" => $zip]);
  }

  function validateOrigin($origin) {

    if (( array_key_exists("lon", $origin) === FALSE || array_key_exists("lat", $origin) === FALSE ) )
    {
      if (array_key_exists("zip", $origin)) {
        throw new RestfulNotFoundException("Postal code was given but unable to resolve its geolocation.");
        return false;
      }
      else {
        throw new RestfulNotFoundException("Unable to locate. lat/lon params are required.");
        return false;
      }
    }

    if (((float)$origin['lat'] == 0 || (float)$origin['lon'] == 0))
    {
      throw new RestfulNotFoundException("Longitude/Latitude or Zip/Postal code needs to be a non-zero number (float)");
      return false;
    }

    if ((int)$zip == 0) {
      if (((float)$origin['lat'] == 0 && (float)$origin['lon'] == 0))
      {
        throw new RestfulNotFoundException("Longitude/Latitude or Zip/Postal code needs to be a non-zero number (float)");
        return false;
      }
    }

    return true;
  }

}

Drupal Store Locator - Part 2

2015-JAN-26:

Second post in my series "Drupal Store Locator" which is an update of a chapter from my book "Drupal Mobile Web Development: A Beginner's Guide".

Now that we have the storage container, we need a way of importing and exporting raw data into and out of the container. For that I took at look at the 3 REST API modules, Services, REST and RESTful. The one that made the most sense to me was RESTful because it basically allows you to write classes that represent your exposed data and tightly control how the data is imported.

Services

The bellweather module for implementing REST API's in Drupal is the Services. There are, however, a couple of issues for which services does not adequately work. First and foremost is the API is not structured according to the standard noun/verb pattern that has become the standard for API's and I found (my opinion here) posting data and getting that data saved to drupal's database to be cumbersome at best and at worst, impossible.

REST

REST is an abandoned project that follows the API Pattern, e.g. POST /blog/entry +[data] = new entry. The problem is that it has, in fact, been abandoned and will not be updated, making this solution undesirable for that reason alone.

RESTful

Restful is a project by the guys over at Gizra. The way it differs is that the module itself does not expose any content to the REST interface, rather it is an API by which content can be mad available. The developer must explicitly write classes for each piece of exposed content. This may sound scary, but like I always say, "Let go of my ears and lean back, I've done this before."

Its About Creation

So in evaluating REST frameworks, I tried creating content with the REST interface. While I eventually got each module to work, I found the one that was most customizable was RESTful because in writing the class, I could handle extra arguments in the class and run some error checking on the back end if I cared to do so. Also, the added bonus of exposing only the content types I cared to was very attractive. Whereas the other two REST frameworks exposed all content types to REST actions, RESTful only exposes content types for which you write a data class. This made sense to me.

The class I built was as follows:

/*
 *  @file {module}/plugins/restful/node/partner/1.0/AnkiRestfulPartnerResource.class.php
 *
 *
*/


class AnkiRestfulPartnerResource extends RestfulEntityBaseNode {

  /**
   * Overrides RestfulExampleArticlesResource::publicFieldsInfo().
   */

  public function publicFieldsInfo() {
    $public_fields = parent::publicFieldsInfo();

    $public_fields['nid'] = array(
      'property' => 'nid',
    );
    $public_fields['vid'] = array(
      'property' => 'vid',
    );
    $public_fields['status'] = array(
      'property' => 'status',
    );
    $public_fields['uuid'] = array(
      'property' => 'uuid',
    );
    $public_fields['language'] = array(
      'property' => 'language',
    );

    $public_fields['location'] = array(
      'property' => 'field_location',
    );
    return $public_fields;
  }

}

The publicFieldsInfo method exposes which fields will be available to the REST interface. Calling the parent on the first line of the method adds the node title to the list.

What I wanted to be able to do was to move the content from environment to environment without having to move the Drupal database so I wrote a script that does the import via REST with a few classes that extended Guzzle. This is a separate project and separate stack from the Drupal repo. This will be used almost entirely from the command line:

/*
 *  @file {import/export project}/src/Drupal/Partner.php
 *
 *
*/

namespace Drupal\REST;

use GuzzleHttp\Message\Request;
use GuzzleHttp\Post\PostFile;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\CssSelector\CssSelector;

/**
 * Class Node
 * @package Drupal\REST
 */
class Partner extends Base {

  static $addressFieldTranslation = [
    "state" => "province",
    "zip" => "postal_code",
    "street2" => "additional",
    "address" => "street",
  ];



  public $remoteInfo = [
    "label" => null,
    "location" => [
      "name" => null,
      "street" => null,
      "additional" => null,
      "city" => null,
      "province" => null,
      "postal_code" => null,
      "country" => "us",
      "latitude" => null,
      "longitude" => null,
      "source" => null,
      "is_primary" => 1,
      "province_name" => null,
      "email" => null,
      "fax" => null,
      "phone" => null
    ],

  ];



  function __construct($values) {
    if (empty($values)) {
      return null;
    }
    foreach ($values as $key => $value) {
      $this->setValue($key, $value);
    }
  }

  function save() {
    try {
      $config = Config::get_config(true);
      $client = Config::get_rest_client('restful');
      $resp = $client->post($config['drupalRestEndpoint']."/en/api/v1/partner", [
        "debug" => false,
        "json" => $this->__toArray()
      ]);
      if (in_array($resp->getStatusCode(), [200, 201, 202])) {
        echo $this->getValue("label")." has been saved.".PHP_EOL;
      }
    } catch(Exception $e) {
      if ($e instanceOf RequestException) {
        echo $e->getRequest() . PHP_EOL;
        if ($e->hasResponse()) {
          echo $e->getResponse() . PHP_EOL;
        }
      } else {
        echo $e->getMessage();
      }
    }
  }

  function setValue($key, $value) {
    if (in_array($key, array_keys(self::$addressFieldTranslation))) {
      $this->remoteInfo['location'][self::$addressFieldTranslation[$key]] = $value;
    } elseif(in_array($key, array_keys($this->remoteInfo['location']))) {
      if ($key == "country") {
        $this->remoteInfo['location'][$key] = strtolower($value);
      } else {
        $this->remoteInfo['location'][$key] = $value;
      }
    } else {
      $this->remoteInfo[$key] = $value;
    }
  }

  function getValue($key) {
    if (in_array($key, array_keys(self::$addressFieldTranslation))) {
      return $this->remoteInfo['location'][self::$addressFieldTranslation[$key]];
    } elseif (array_key_exists($key, $this->remoteInfo['location'])) {
      return $this->remoteInfo[$key];
    } elseif (array_key_exists($key, $this->remoteInfo)) {
      return $this->remoteInfo[$key];
    } else {
      return null;
    }
  }


  function __toJSON() {
    return json_encode($this->__toArray());
  }

  function __toArray() {
    $toReturn = [
      "label" => $this->getValue("label"),
      "location" => $this->getValue("location"),
      "language" => "en",
      "status" => 1,
    ];
    $toReturn['location']['name'] = $toReturn['label'];
    return $toReturn;
  }

}

This class takes an array as constructor argument and posts that array to the drupal resource /en/api/v1/partner. We'll use this class to script the import from a standard CSV of partner locations and then use the script below to do the work of running the import:

#!/usr/bin/env php
<?php

/*
 *  @file {import/export project}/bin/importPartners.php
 *
 *
*/


ini_set("auto_detect_line_endings", true);

//error_reporting(E_ERROR | E_PARSE);

require_once(__DIR__ . "/../config/config.php");

if (!array_key_exists("GRUNT_CONFIG", $_ENV)) {
  $config = \Drupal\REST\Config::get_config();
}


use GuzzleHttp\Post\PostFile;
use GuzzleHttp\Exception\RequestException;
use Drupal\REST\Node;


$script = array_shift($argv);
$argc--;

if ($argc) {
  echo "Importing {$argv[0]} into drupal env ".$config["drupalRestEndpoint"].PHP_EOL;
  $incoming = [];
  try{
    if (file_exists($argv[0])) {
      $toImport = file($argv[0]);
      foreach($toImport as $line) {
        $incoming[] =  explode(",", substr($line, 0, -2));
      }
      $keys = array_shift($incoming);
    }

    if (!empty($incoming)) {
      foreach($incoming as $rowNum => $row) {
        $partner = new \Drupal\REST\Partner(array_combine($keys,$row));
        if ($partner instanceOf \Drupal\REST\Partner) {
          $partner->save();
        }
      }
    }

  } catch(Exception $e) {
    if ($e instanceOf RequestException) {
      echo $e->getRequest() . PHP_EOL;
      if ($e->hasResponse()) {
        echo $e->getResponse() . PHP_EOL;
      }
    } else {
      echo $e->getMessage();
    }
    print_r(debug_backtrace());
  }
}

Notice that the first line starts with a #!/usr/bin/env php. This as well as changing the file permissions to allow execution (chmod +x) will allow us to call this script from the command line with a single argument that represents the data CSV.

Part 3 will describe the front end user interface once we've got the data imported.

Drupal Store Locator - Part 1

2015-JAN-21:

A year or so ago, I wrote a book called "Drupal Mobile Web Development: A Beginner's Guide". In the book I detail how to make a store locator with Drupal and a few mapping modules.

I just did another one for my current employer Anki and the process was a bit different than the book example for a couple of reasons. First and foremost, we wanted the store locator data to eventually be available to our Android and iOS mobile apps. Second, there are some new front-end modules that will give you more options on the front end now than there were when I wrote the book... most notably "Leaflet", a javascript library for interacting with map data in browsers. I also wanted to create a solution that was easy enough to upgrade once I decided to take the site to Drupal 8. This is the first in a series of articles detailing how I did what I did and why.

First, let's discuss the data store we will be using to hold the map data. The data I was given to place points on the map was a series of stores in which our flagship product, Anki Drive is sold. For those points, I was given addresses in the US, Canada and the UK. I need a way to store that address information and more importantly, the Longitude and Latitude of each of those addresses because we can't map their location without long/lat coordinates.

There are a couple of modules that do this. The Location module series stores all data about a single location in a table separate from standard field data. It doesn't work with the standard Field API and it doesn't work with the Entity API, which are two pretty big strikes against it. But it does its job really well. There's an alternative path using the field API: Geolocation and Address field. This is two unrelated modules that use the field API where Location is designed as an inter-operative suite. I tried the geolocation/address field solution (which is in keeping with best practices for Drupal 8), but there geare currently some outstanding issues with each module. The most significant issue being google Geocoding is broken in the geolocation module (or at least the issue was unresolved when I created the Anki map.

Geocoding is the process of taking an address and turning it into a long/lat pair. More about that process in a bit.

Location is the more mature solution and it works with several geocoding providers. We have a google geocoding API account we use for other purposes so this presented itself as the best solution for the moment.

I created a content object that has a location attached to it as well as a title and description. With the location module, the locations are "attached" to nodes and exists as non-entity data. There's a secondary module that will auto-load location data when entities are loaded (location_entity).

Expect parts 2 nd 3 within a week or so.

The Hunt

2014-MAR-10:

I've been laid off before. In 2009 I was laid off by HealthPlan Services. A couple of things strike me about this situation. First, how many more open positions are out there than there were in 2009. In less than a week I had multiple interviews and a few more schedule. I had multiple recruiters calling me with open positions and tons of results showing up online. Two reasons: 1. the economy's better. 2. I'm living in the technology capital of the universe.

Second, It occurs to me how much less the Silicon Valley firm gave me in severance. It might have been a realization that the moment I got laid off in 2009 was the same time THE REST OF THE KNOWN UNIVERSE got laid off, but Healthplan was gracious enough to give me like 3 months of severance. I got considerably less from Apigee. After two years. I joined the company when there were about 50 employees. Two and a half years later, there are now approximately five hundred employees. As to work environments, there's no question of Apigee being a superior employer in every measurable way except for this. I just found this one thing odd.

But it might be a realization that there are, indeed, a TON of jobs available locally and the average job search in our industry is 2-4 weeks. But still... Anywho, not complaining. I got enough money to survive on until I've gotten an offer. And the way the interviews are going, that should be any day.

And I'm eligible for a lot more unemployment than Florida. In California, it's like $475/week. In Florida, it was about $275/week. So there's that. Of course my rent is DOUBLE what it was in Florida and unemployment only barely covers it.

Its also really cool the TYPES of jobs i'm being offered. Everything from strictly project management to DevOps.

good times and bad times, i've had them all and my dear i'm still here.

-from "I'm still here"

Creating a Drupal 7 Distribution

2014-MAR-01:

When we first started engaging with Pantheon, it became clear we would need a true Drupal distribution. We had simply created a codebase and pushed a pre-configured database a tarball codebase. That wasn't going to cut it. So I started on my journey to add to the install profile everything needed to get a fully-formed Apigee portal up and going. Pantheon's Instructions where helpful but it took a lot of trial and error. I must have installed from scratch the portal at least 1000 times. That's not an exaggeration.

Some of the things you don't realize at the outset:

  • Complex tasks need to be split up into smaller tasks and submitted to the installer as a batch array so they don't time out the installer. Just because it works on your local version doesn't mean it's going to work in a pantheon hosting environment. They have throttled CPUs and limited resources. You have to take this into an account.

  • In the module install batch, Modules will install in alpha order, no matter how you add them to the array. And they don't always install their dependencies. It behooves you to iterate thru their dependencies and add them to the array of modules to install.

more as I think of them.

goodbye, apigee

2014-FEB-28:

"Everything that has a beginning, also has an ending." -The Oracle (from the matrix trilogy)

Yesterday I was laid off from Apigee. It's incredibly disappointing because this was a job that I truly loved. Cost cutting is forcing them to downsize their engineering staff and I was the "most expensive" one on the team.

I wish the ones still working on my product well and I really think the future Apigee is very bright. It's a great space and the company does a really good job executing their primary mission.

Oh, well.. on to other things.

Currently seeking a job in LAMP stack development. With or without Drupal. If you know of anything, hit me on one of the social networks.

i no u

2013-APR-02:

i no u

when we were in school

u said u wouldn't tell

but u did


i no u

when we were in love

u said u wouldn't run

but u did


i no u

when we were in church

u said u would love

but u couldn't give

anything

at least anything that would make a difference

is it prejudice

2 believe in ur lack of vision?

is it hate

2 no u eye unseen?


i no ur way

u r 2 double minus signs

at the end of every name

u subtract everything

when u speak its to say "no"

when u look, it's to not go

when u leave its because u're too slow

we're running and u're laggin behind

in ur mind

we're shining and u're hidin in the rain

behind a name

we're gonna to fly and u're the one

with ur face to the back of the bus

icarus

he didn't no what we no

he didn't see what we see

the things we imagine

the things we create

the things we conceive

will come to be

but u can't see

see?

i no u

Moving to Github

2013-JAN-23:

As one of my new year's resolutions to save money, I'm moving my blog out of Drupal to Github pages. Basically Github pages, if you don't already know about them, are run by a Ruby-based product called "Jekyll". You create posts named correctly and created with Markdown, give it some theming info and it "compiles" the pages down into a series of static html pages which are then hosted by Github's servers.

It's a nice system. Also blogging with a text editor like ia writer is nothing short of awesome. One of the major criticisms of Drupal was the editorial experience. The process of content creation was perceived as being more difficult than a standard text editor.

No argument from me. I, personally, can't stand CK editor and did all my Drupal posts in Markdown anyway using the Drupal Markdown Module.

How to choose a barber shop - part 1

2012-JUL-09:

The strip malls of America are littered with multiple franchise chains of hair cut shops and have relegated the humble father and son barber shop to a thing of legend and lore. But if you look carefully, you can still find a real all- American barber shop in most cities in America. Here's some selection criteria to guide your search.

whats in a name?

First, the name should be the first or last name of the owner: Billy's barber shop, Rick's Barber Shop, Smith's cut and shave, Jimmy's, Habeeb's and sons... Whatever the nationality, you're buying a person's expertise not a 'hair philosophy.' And preferably a male's name, although the best haircut I ever got was from a lesbian barber. But her name was Sam, somewhat proving my point yet again.

Second, it should never contain a hair or grooming pun: 'From Hair On', 'The Mane Event'. Ick. Run, don't walk, in the other direction. Third, never a misspelling of cut or clippers. 'Cutz', 'Scizzors'... Ew. My second grade teacher of a mother would take a red pen and correct the signs and letterhead. If you can't be trusted to spell you profession correctly, I don't trust you with my hair. ##Interior Upon walking in the front door, it should be clear that this is a place where real men have been discussing women, religion and politics since the Reagan administration. There should be no 'theme' in the decor other than barbershop. If there's a television, it should be on sports.

If there's a radio, it should be news or country. Never Fox News and never right-wing talk radio. There should be nothing offensive or overly sales-y. It should not be clear what political party any of the barbers belong to. All political signs should be local: 'Elect Charlie Fox, City Council' 'Vote yes on Referendum 41: our kids deserve better schools'. It should be clear they care about their city or state but not in a 'beat you over the head with it' sort of way. There should be no large photos of stylish people doing things outdoors. If there's any advertising it should be for grooming products that men use.

more soon

Chicken and pancakes part 2

2012-MAY-27:

Sunday morning a week or so after the first incident, walk back into the same iHop in San Bruno.

I want the chicken and waffles, but instead of waffles, I want pancakes."

"I'm sorry, we can't do that." the iHop waitress said.

you can charge me extra, just substitute a short stack.

no,I'm sorry, we can't do that."

Um, you have chicken strips, right

Yes.

what about pancakes

jes, we have the pancakes.

...Then put them together on a plate and serve them to me.

I can't do that. It's chicken and waffles. That's the special.

At this point, I get in the car and drive ten miles to the Denny's in San Mateo. "Can I get the Chicken Strips dinner, but instead of sides I just want a short stack of pancakes." "no sir, that's the dinner menu. You can only get dinner sides." How much power over the time/space continuum is required to create an order of chicken strips and add an order of pancakes to it

Waiter comes back several min later and let's me know the manager 'approved' it. Apparently the manager has great power.

2015

February

January

2014

March

February

2013

April

January

2012

July

May

April

March

January

2011

December

November

October

June

May

March

February

2010

December

September

May

April

March

January

2009

October

September

August

July

May

April

March

February

January

2008

December

November

October

September

August

July

June

May

April

March

February

January

2007

December

November

August

July

June

May

April

March

February

January

2006

March

February

January

2005

December

November

October

September

August

July

June

May

2001

December

All Posts