entity properties

From: fago <nuppla@zites.net>


---

 includes/common.inc                        |   22 ++
 includes/module.inc                        |    9 +
 includes/properties.inc                    |  285 ++++++++++++++++++++++++++++
 includes/token.inc                         |  171 ++++++++---------
 modules/book/book.module                   |   42 ++++
 modules/field/field.default.inc            |    4 
 modules/field/field.info.inc               |  112 +++++++++++
 modules/field/field.module                 |   17 +-
 modules/field/modules/number/number.module |    3 
 modules/field/modules/text/text.module     |    4 
 modules/node/node.entity.inc               |  120 ++++++++++++
 modules/node/node.info                     |    2 
 modules/node/node.module                   |   62 +++---
 modules/system/system.module               |   55 +++++
 modules/system/system.test                 |  149 ++++++++++++++-
 modules/user/user.entity.inc               |   74 +++++++
 modules/user/user.info                     |    2 
 modules/user/user.module                   |   37 +---
 18 files changed, 999 insertions(+), 171 deletions(-)
 create mode 100644 includes/properties.inc
 create mode 100644 modules/node/node.entity.inc
 create mode 100644 modules/user/user.entity.inc


diff --git includes/common.inc includes/common.inc
index 5cea431..a40ebfe 100644
--- includes/common.inc
+++ includes/common.inc
@@ -5090,6 +5090,7 @@ function entity_get_info($entity_type = NULL) {
       $entity_info = $cache->data;
     }
     else {
+      module_load_all_includes('inc', 'entity');
       $entity_info = module_invoke_all('entity_info');
       // Merge in default values.
       foreach ($entity_info as $name => $data) {
@@ -5188,3 +5189,24 @@ function xmlrpc($url) {
   return call_user_func_array('_xmlrpc', $args);
 }
 
+/**
+ * Returns a DrupalPropertyEntityWrapper or DrupalPropertyFormatWrapper
+ * dependent whether the passed data is an entity.
+ *
+ * @param $type
+ *   The type of the passed data.
+ * @param $data
+ *   The data to wrap.
+ * @param $options
+ *   (optional) A keyed array of options. The supported options vary by class.
+ * @param $context
+ *   (optional) An array of contextual information which format callbacks can
+ *   make use of.
+ * @return
+ *   An instance of DrupalPropertyWrapperInterface.
+ */
+function drupal_get_property_wrapper($type, $data, array $options = array(), $context = array()) {
+  $entity_info = entity_get_info();
+  $class = isset($entity_info[$type]) ? 'DrupalPropertyEntityWrapper' : 'DrupalPropertyFormatWrapper';
+  return new $class($type, $data, $options, $context);
+}
diff --git includes/module.inc includes/module.inc
index bde7f5d..aff42c0 100644
--- includes/module.inc
+++ includes/module.inc
@@ -176,11 +176,18 @@ function module_load_include($type, $module, $name = NULL) {
 /**
  * Load an include file for each of the modules that have been enabled in
  * the system table.
+ *
+ * @param $type
+ *   The include file's type (file extension).
+ * @param $name
+ *   Optionally, specify the base file name to include "$module.$name.$type".
+ *   If not set, "$module.$type" is used.
  */
 function module_load_all_includes($type, $name = NULL) {
   $modules = module_list();
+  $name = isset($name) ? '.'. $name : '';
   foreach ($modules as $module) {
-    module_load_include($type, $module, $name);
+    module_load_include($type, $module, $module . $name);
   }
 }
 
diff --git includes/properties.inc includes/properties.inc
new file mode 100644
index 0000000..fc55db4
--- /dev/null
+++ includes/properties.inc
@@ -0,0 +1,285 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Provides the drupal property wrapper classes.
+ */
+
+/**
+ * A common interface for all property wrappers. Each wrapper can be used
+ * to derive some values depending on the implementation. If possible another
+ * instance of DrupalPropertyWrapperInterface should be returned to allow
+ * chained usage like
+ * @code
+ *   echo $wrapper->node->author->name;
+ * @endcode
+ */
+interface DrupalPropertyWrapperInterface extends IteratorAggregate {
+  
+  /**
+   * Constructor.
+   *
+   * @see drupal_get_property_wrapper().
+   */
+  public function __construct($type, $data, array $options = array());
+  
+  /**
+   * Get the data wrapped by this object.
+   */
+  public function get();
+  
+  /**
+   * Magic method: Get a derived value.
+   */
+  public function __get($name);
+  
+  /**
+   * Magic method: Check whether a derived value of this name is supported.
+   */
+  public function __isset($name);
+
+}
+
+/**
+ * Provides a wrapper for entities which eases dealing with entity properties.
+ */
+class DrupalPropertyEntityWrapper implements DrupalPropertyWrapperInterface {
+  
+  protected $entityType;
+  protected $entity;
+  protected $info;
+  protected $options;
+  protected $cache = array();
+  
+  /**
+   * Construct a new DrupalPropertyEntityWrapper object.
+   *
+   * @param $entityType
+   *   The type of the passed entity.
+   * @param $entity
+   *   The entity with which properties we deal.
+   * @param $options
+   *   (optional) A keyed array of options. Supported are:
+   *   - language: A language object to be used when getting locale-sensitive
+   *     properties.
+   *   - sanitize: A boolean flag indicating that textual properties should be
+   *     sanitized for display to a web browser. Defaults to FALSE.
+   *   - bundle: The bundle of the passed entity. If specified, bundle specific
+   *     properties are available too.
+   *   - tags: An array of tags assigned to the passed entity. If specified,
+   *     properties specific to the respective entities are available too.
+   */
+  public function __construct($entityType, $entity, array $options = array()) {
+    $this->entityType = $entityType;
+    $this->entity = $entity;
+    $this->info = entity_get_info($entityType) + array('properties' => array());
+    $this->options = $options + array('sanitize' => FALSE, 'language' => NULL, 'tags' => array());
+
+    // Add in properties from the bundle or tags, if specified.
+    if (isset($this->options['bundle']) && isset($this->info['bundles'][$this->options['bundle']])) {
+      $bundle_info = $this->info['bundles'][$this->options['bundle']] + array('properties' => array(), 'tags' => array());
+      $this->info['properties'] += $bundle_info['properties'];
+      $this->options['tags'] = array_merge($this->options['tags'], $bundle_info['tags']);
+    }
+    foreach ($this->options['tags'] as $tag) {
+      if (isset($this->info['tags'][$tag]['properties'])) {
+        $this->info['properties'] += $this->info['tags'][$tag]['properties'];
+      }
+    }
+  }
+  
+  /**
+   * Gets the info about the given property.
+   *
+   * @param $name
+   *   The name of the property.
+   * @throws DrupalPropertyWrapperException
+   *   If there is no such property.
+   * @return
+   *   An array of info about the property.
+   */
+  public function getPropertyInfo($name) {
+    if (!isset($this->info['properties'][$name])) {
+      throw new DrupalPropertyWrapperException('Unknown entity property '. check_plain($name). '.');
+    }
+    $info = $this->info['properties'][$name] + array(
+      'type' => 'text',
+      'bundle' => NULL,
+      'tags' => array(),
+      'formats' => array(),
+      'default format' => NULL,
+    );
+    return $info + array('sanitize' => (empty($info['getter callback']) && $info['type'] == 'text' ? 'check_plain' : NULL));
+  }
+
+  /**
+   * Magic method: Get a property.
+   *
+   * @return
+   *   An instance of DrupalPropertyWrapperInterface.
+   */
+  public function __get($name) {
+    // Look it up in the cache if possible.
+    if (!array_key_exists($name, $this->cache)) {
+      $info = $this->getPropertyInfo($name);
+      $this->cache[$name] = NULL;
+
+      if (!empty($info['getter callback']) && function_exists($info['getter callback'])) {
+        $this->cache[$name] = $info['getter callback']($this->entity, $this->options, $name, $this->entityType);
+      }
+      elseif (is_array($this->entity) && isset($this->entity[$name])) {
+        $this->cache[$name] = $this->entity[$name];
+      }
+      elseif (is_object($this->entity) && isset($this->entity->$name)) {
+        $this->cache[$name] = $this->entity->$name;
+      }
+      // Sanitize values.
+      if (isset($this->cache[$name]) && !empty($this->options['sanitize']) && !empty($info['sanitize']) && function_exists($info['sanitize'])) {
+        $this->cache[$name] = $info['sanitize']($this->cache[$name]);
+      }
+      // Return another wrapper to support chained usage.
+      $options = array_intersect_key($info, drupal_map_assoc(array('bundle', 'tags', 'default format', 'formats'))) + $this->options;
+      $context = array('entity' => $this->entity, 'entity type' => $this->entityType, 'property' => $name);
+      $this->cache[$name] = drupal_get_property_wrapper($info['type'], $this->cache[$name], $options, $context);
+    }
+    return $this->cache[$name];
+  }
+  
+  /**
+   * Magic method: Set a property.
+   */
+  public function __set($name, $value) {
+    $info = $this->getPropertyInfo($name);
+    if (!empty($info['setter callback']) && function_exists($info['setter callback'])) {
+      unset($this->cache[$name]);
+      return $info['setter callback']($this->entity, $name, $value, $this->entityType);
+    }
+    throw new DrupalPropertyWrapperException('Entity property '. check_plain($name). " doesn't support writing.");
+  }
+  
+  /**
+   * Magic method: isset() can be used to check if a property is known.
+   */
+  public function __isset($name) {
+    return isset($this->info['properties'][$name]);
+  }
+  
+  /**
+   * Get the entity wrapped by this object.
+   */
+  public function get() {
+    return $this->entity;
+  }
+  
+  public function getIterator() {
+    return new ArrayIterator(array_keys($this->info['properties']));
+  }
+}
+
+
+/**
+ * Class that eases applying token formats for returned properties.
+ */
+class DrupalPropertyFormatWrapper implements DrupalPropertyWrapperInterface {
+  
+  protected $type;
+  protected $data;
+  protected $info;
+  protected $options;
+  protected $context;
+  
+  /**
+   * Construct a new DrupalPropertyFormatWrapper object.
+   *
+   * @param $type
+   *   The type of the passed data.
+   * @param $data
+   *   The data to format.
+   * @param $options
+   *   (optional) A keyed array of options. Supported are:
+   *   - language: A language object to be used when generating
+   *     locale-sensitive formats.
+   *   - formats: An array of further formats for this property
+   *     as defined in hook_entity_info().
+   *   - default format: Customize the default format.
+   * @param $context
+   *   (optional) An array of contextual information which format callbacks
+   *   can make use of. Contains 'entity', 'entity_type' and 'property' when
+   *   constructed by the DrupalPropertyEntityWrapper.
+   */
+  public function __construct($type, $data, array $options = array(), $context = array()) {
+    $this->type = $type;
+    $this->data = $data;
+    $this->options = $options + array('formats' => array());
+    $this->context = $context;
+  }
+  
+  /**
+   * We use this to init $this->info only when needed.
+   */
+  protected function initInfo() {
+    if (!isset($this->info)) {
+      $this->info = token_get_format_info($this->type) + array('formats' => array());
+      $this->info['formats'] = $this->options['formats'] + $this->info['formats'];
+    }
+  }
+  
+  /**
+   * Magic method: Format the data.
+   */
+  public function __get($name) {
+    $this->initInfo();
+    if (!isset($this->info['formats'][$name])) {
+      throw new DrupalPropertyWrapperException('Unknown format '. check_plain($name). '.');
+    }
+    return token_format($this->data, $this->type, $name, $this->options, $this->context);
+  }
+
+  /**
+   * Magic method: isset() can be used to check if a format is known.
+   */
+  public function __isset($name) {
+    $this->initInfo();
+    return isset($this->info['formats'][$name]);
+  }
+  
+  /**
+   * Get the data wrapped by this object.
+   */
+  public function get() {
+    return $this->data;
+  }
+  
+  public function getIterator() {
+    $this->initInfo();
+    return new ArrayIterator(array_keys($this->info['formats']));
+  }
+  
+  /**
+   * For converting to a string use the default format, if any.
+   */
+  public function __toString() {
+    return (string)token_format($this->data, $this->type, NULL, $this->options, $this->context);
+  }
+}
+
+/**
+ * Provide a separate Exception so it can be caught separately.
+ */
+class DrupalPropertyWrapperException extends Exception {
+  
+}
+
+/**
+ * Sets the property to the given value. May be used as 'setter callback'.
+ */
+function drupal_property_verbatim_set(&$entity, $name, $value) {
+  if (is_array($entity)) {
+    $entity[$name] = $value;
+  }
+  elseif (is_object($entity)) {
+    $entity->$name = $value;
+  }
+}
+
diff --git includes/token.inc includes/token.inc
index 6f832b7..70bb273 100644
--- includes/token.inc
+++ includes/token.inc
@@ -42,8 +42,8 @@
  * and 'mail' is a placeholder available for any 'user'.
  *
  * @see token_replace()
- * @see hook_tokens()
- * @see hook_token_info()
+ * @see hook_token_format_info()
+ * @see hook_entity_info()
  */
 
 /**
@@ -71,14 +71,15 @@
  *     display to a web browser. Defaults to TRUE. Developers who set this option
  *     to FALSE assume responsibility for running filter_xss(), check_plain() or
  *     other appropriate scrubbing functions before displaying data to users.
+ * @param $types
+ *   (optional) An array mapping the keys of $data to data types. If there is
+ *   no mapping for a key, the data's key is used as type by default.
  * @return
  *   Text with tokens replaced.
  */
-function token_replace($text, array $data = array(), array $options = array()) {
-  $replacements = array();
-  foreach (token_scan($text) as $type => $tokens) {
-    $replacements += token_generate($type, $tokens, $data, $options);
-  }
+function token_replace($text, array $data = array(), array $options = array(), array $types = array()) {
+  $token_list = token_scan($text);
+  $replacements = token_generate($token_list, $data, $options, $types);
 
   // Optionally alter the list of replacement values.
   if (!empty($options['callback']) && function_exists($options['callback'])) {
@@ -122,11 +123,8 @@ function token_scan($text) {
 /**
  * Generate replacement values for a list of tokens.
  *
- * @param $type
- *   The type of token being replaced. 'node', 'user', and 'date' are common.
- * @param $tokens
- *   An array of tokens to be replaced, keyed by the literal text of the token
- *   as it appeared in the source text.
+ * @param $raw_tokens
+ *   A keyed array of tokens, and their original raw form in the source text.
  * @param $data
  *   (optional) An array of keyed objects. For simple replacement scenarios
  *   'node', 'user', and others are common keys, with an accompanying node or
@@ -145,107 +143,108 @@ function token_scan($text) {
  *     display to a web browser. Developers who set this option to FALSE assume
  *     responsibility for running filter_xss(), check_plain() or other
  *     appropriate scrubbing functions before displaying data to users.
+ * @param $types
+ *   (optional) An array mapping the keys of $data to data types. If there is
+ *   no mapping for a key, the data's key is used as type by default.
  * @return
  *   An associative array of replacement values, keyed by the original 'raw'
  *   tokens that were found in the source text. For example:
  *   $results['[node:title]'] = 'My new node';
  */
-function token_generate($type, array $tokens, array $data = array(), array $options = array()) {
+function token_generate(array $raw_tokens, array $data = array(), array $options = array(), array $types = array()) {
   $results = array();
   $options += array('sanitize' => TRUE);
-  _token_initialize();
+  // Add in the current date and the global user by default.
+  $data += array('date' => REQUEST_TIME, 'current-user' => $GLOBALS['user']);
+  $types += array('current-user' => 'user');
 
-  $result = module_invoke_all('tokens', $type, $tokens, $data, $options);
-  foreach ($result as $original => $replacement) {
-    $results[$original] = $replacement;
+  foreach ($raw_tokens as $key => $tokens) {
+    $type = isset($types[$key]) ? $types[$key] : $key;
+    if (isset($data[$key]) && ($wrapper = drupal_get_property_wrapper($type, $data[$key], $options))) {
+      foreach ($tokens as $token => $original) {
+        try {
+          $results[$original] = _token_get_replacement($wrapper, $token);
+        }
+        catch (DrupalPropertyWrapperException $e) {
+          // A token has not been found, so ignore it.
+        }
+      }
+    }
   }
-
   return $results;
 }
 
 /**
- * Given a list of tokens, return those that begin with a specific prefix.
- *
- * Used to extract a group of 'chained' tokens (such as [node:author:name]) from
- * the full list of tokens found in text. For example:
- * @code
- *   $data = array(
- *     'author:name' => '[node:author:name]',
- *     'title'       => '[node:title]',
- *     'created'     => '[node:author:name]',
- *   );
- *   $results = token_find_with_prefix($data, 'author');
- *   $results == array('name' => '[node:author:name]');
- * @endcode
- *
- * @param $tokens
- *   A keyed array of tokens, and their original raw form in the source text.
- * @param $prefix
- *   A textual string to be matched at the beginning of the token.
- * @param $delimiter
- *   An optional string containing the character that separates the prefix from
- *   the rest of the token. Defaults to ':'.
- * @return
- *   An associative array of discovered tokens, with the prefix and delimiter
- *   stripped from the key.
+ * Applies chained tokens by getting properties of the given wrapper.
  */
-function token_find_with_prefix(array $tokens, $prefix, $delimiter = ':') {
-  $results = array();
-  foreach ($tokens as $token => $raw) {
-    $parts = split($delimiter, $token, 2);
-    if (count($parts) == 2 && $parts[0] == $prefix) {
-      $results[$parts[1]] = $raw;
-    }
+function _token_get_replacement(DrupalPropertyWrapperInterface $wrapper, $token) {
+  foreach (explode(':', $token) as $i => $name) {
+    $wrapper = $wrapper->$name;
   }
-  return $results;
+  // If no format was given apply the default just by converting it to string.
+  return (string)$wrapper;
 }
 
 /**
- * Returns metadata describing supported tokens.
+ * Returns metadata describing token formats.
  *
- * The metadata array contains token type, name, and description data as well as
- * an optional pointer indicating that the token chains to another set of tokens.
- * For example:
- * @code
- *   $data['types']['node'] = array(
- *     'name' => t('Nodes'),
- *     'description' => t('Tokens related to node objects.'),
- *   );
- *   $data['tokens']['node']['title'] = array(
- *     'name' => t('Title'),
- *     'description' => t('The title of the current node.'),
- *   );
- *   $data['tokens']['node']['author'] = array(
- *     'name' => t('Author'),
- *     'description' => t('The author of the current node.'),
- *     'type' => 'user',
- *   );
- * @endcode
- * @return
- *   An associative array of token information, grouped by token type.
+ * @param $type
+ *   The type, e.g. date, for which the info shall be returned, or NULL
+ *   to return an array with info about all types.
+ *
+ * @see hook_token_format_info()
+ * @see hook_token_format_info_alter()
  */
-function token_info() {
+function token_get_format_info($type = NULL) {
   $data = &drupal_static(__FUNCTION__);
   if (!isset($data)) {
-    _token_initialize();
-    $data = module_invoke_all('token_info');
-    drupal_alter('token_info', $data);
+    if ($cache = cache_get('token_format_info')) {
+      $data = $cache->data;
+    }
+    else {
+      $data = module_invoke_all('token_format_info');
+      drupal_alter('token_format_info', $data);
+      cache_set('token_format_info', $data);
+    }
+  }
+  if (!empty($type)) {
+    return isset($data[$type]) ? $data[$type] : array();
   }
   return $data;
 }
 
 /**
- * Load modulename.tokens.inc for all enabled modules.
+ * Formats a token by using a token format as defined in an implementation of
+ * hook_token_format_info().
+ *
+ * @param $value
+ *   The token value to format.
+ * @param $type
+ *   The type of the passed token.
+ * @param $format
+ *   (optional) The format to apply. If unset, the default format will be used.
+ * @param $options
+ *   (optional) A keyed array of options. Supported are:
+ *   - language: A language object to be used when generating locale-sensitive
+ *     formats.
+ *   - formats: Only used internally to add in further formats.
+ *   - default format: Only used internally to customize the default format.
+ * @param $context
+ *   (optional) An array of contextual information which format callbacks
+ *   can make use of. Used internally.
  */
-function _token_initialize() {
-  $initialized = &drupal_static(__FUNCTION__);
-  if (!$initialized) {
-    foreach (module_list() as $module) {
-      $filename = DRUPAL_ROOT . '/' . drupal_get_path('module', $module) . "/$module.tokens.inc";
-      if (file_exists($filename)) {
-        include_once $filename;
-      }
-    }
-    $initialized = TRUE;
+function token_format($value, $type, $format = NULL, array $options = array(), array $context = array()) {
+  $info = token_get_format_info($type) + array('formats' => array(), 'default format' => NULL);
+  $options += array('language' => NULL, 'formats' => array());
+  $formats = $options['formats'] + $info['formats'];
+  if (!isset($format)) {
+    $format = isset($options['default format']) ? $options['default format'] : $info['default format'];
+  }
+  // Now apply the format.
+  if (isset($formats[$format]['callback']) && function_exists($function = $formats[$format]['callback'])) {
+    return $function($value, $format, $options['language'], $context);
+  }
+  elseif (!isset($format)) {
+    return $value;
   }
 }
diff --git modules/book/book.module modules/book/book.module
index 7d31ddc..2c3aa84 100644
--- modules/book/book.module
+++ modules/book/book.module
@@ -1220,3 +1220,45 @@ function book_menu_subtree_data($link) {
 
   return $tree[$cid];
 }
+
+/**
+ * Implement hook_entity_info_alter().
+ */
+function book_entity_info_alter(&$entity_info) {
+  $entity_info['node']['tags']['book'] = array(
+    'label' => t('Book page'),
+  );
+  
+  $properties = &$entity_info['node']['tags']['book']['properties'];
+  $properties['book-id'] = array(
+    'label' => t("Book ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of this page's book."),
+    'getter callback' => 'book_get_properties',
+  );
+  $properties['book'] = array(
+    'label' => t("Book"),
+    'type' => 'node',
+    'tags' => array('book'),
+    'description' => t("The book to which this book page belongs."),
+    'getter callback' => 'book_get_properties',
+  );
+  // Automatically add in the tag for all book node types.
+  foreach (variable_get('book_allowed_types', array('book')) as $type) {
+    $entity_info['node']['bundles'][$type]['tags'][] = 'book';
+  }
+}
+
+/**
+ * Callback for getting book node properties.
+ * @see book_entity_info_alter().
+ */
+function book_get_properties($node, array $options, $name, $entity_type) {
+  switch ($name) {
+    case 'book-id':
+      return $node->book['bid'];
+
+    case 'book':
+      return isset($node->book['bid']) ? node_load($node->book['bid']) : $node;
+  }
+}
diff --git modules/field/field.default.inc modules/field/field.default.inc
index 4b1f377..092f66b 100644
--- modules/field/field.default.inc
+++ modules/field/field.default.inc
@@ -47,7 +47,7 @@ function field_default_insert($obj_type, $object, $field, $instance, $langcode,
   // assigning it a default value. This way we ensure that only the intended
   // languages get a default value. Otherwise we could have default values for
   // not yet open languages.
-  if (empty($object) || !property_exists($object, $field['field_name']) || 
+  if (empty($object) || !property_exists($object, $field['field_name']) ||
     (isset($object->{$field['field_name']}[$langcode]) && count($object->{$field['field_name']}[$langcode]) == 0)) {
     $items = field_get_default_value($obj_type, $object, $field, $instance, $langcode);
   }
@@ -66,7 +66,7 @@ function field_default_view($obj_type, $object, $field, $instance, $langcode, $i
 
   if ($display['type'] !== 'hidden') {
     $theme = 'field_formatter_' . $display['type'];
-    $single = (field_behaviors_formatter('multiple values', $display) == FIELD_BEHAVIOR_DEFAULT);
+    $single = (field_behaviors_formatter('multiple values', $display['type']) == FIELD_BEHAVIOR_DEFAULT);
 
     $label_display = $display['label'];
     if ($build_mode == 'search_index') {
diff --git modules/field/field.info.inc modules/field/field.info.inc
index c29ead5..abd659d 100644
--- modules/field/field.info.inc
+++ modules/field/field.info.inc
@@ -27,6 +27,8 @@ function _field_info_cache_clear() {
   _field_info_collate_types(TRUE);
   drupal_static_reset('field_build_modes');
   _field_info_collate_fields(TRUE);
+  drupal_static_reset('entity_get_info');
+  cache_clear_all('entity_info', 'cache');
 }
 
 /**
@@ -125,6 +127,7 @@ function _field_info_collate_types($reset = FALSE) {
       drupal_alter('field_formatter_info', $info['formatter types']);
 
       // Populate information about 'fieldable' entities.
+      module_load_all_includes('inc', 'entity');
       foreach (module_implements('entity_info') as $module) {
         $entities = (array) module_invoke($module, 'entity_info');
         foreach ($entities as $name => $entity_info) {
@@ -206,7 +209,7 @@ function _field_info_collate_fields($reset = FALSE) {
     }
 
     // Populate 'fields' only with non-deleted fields.
-    $info['field'] = array();
+    $info['fields'] = array();
     foreach ($info['field_ids'] as $field) {
       if (!$field['deleted']) {
         $info['fields'][$field['field_name']] = $field;
@@ -328,15 +331,15 @@ function field_behaviors_widget($op, $instance) {
  *  @param $op
  *    The name of the operation.
  *    Currently supported: 'multiple values'
- *  @param $display
- *    The $instance['display'][$build_mode] array.
+ *  @param $formatter_type
+ *    The formatter type.
  *  @return
  *    FIELD_BEHAVIOR_NONE    - do nothing for this operation.
  *    FIELD_BEHAVIOR_CUSTOM  - use the formatter's callback function.
  *    FIELD_BEHAVIOR_DEFAULT - use field module default behavior.
  */
-function field_behaviors_formatter($op, $display) {
-  $info = field_info_formatter_types($display['type']);
+function field_behaviors_formatter($op, $formatter_type) {
+  $info = field_info_formatter_types($formatter_type);
   return isset($info['behaviors'][$op]) ? $info['behaviors'][$op] : FIELD_BEHAVIOR_DEFAULT;
 }
 
@@ -597,5 +600,104 @@ function field_info_formatter_settings($type) {
 }
 
 /**
+ * Implement hook_entity_info_alter().
+ *
+ * Add metadata about all properties provided by fields.
+ */
+function field_entity_info_alter(&$entity_info) {
+  // Loop over all field instance and add them as property.
+  foreach (field_info_fields() as $field_name => $field) {
+    $field_type = field_info_field_types($field['type']) + array('property_callbacks' => array());
+    // Add in our default callback as the first one.
+    array_unshift($field_type['property_callbacks'], 'field_default_property_callback');
+
+    foreach ($field['bundles'] as $bundle) {
+      $entity_type = field_info_bundle_entity($bundle);
+      $instance = field_info_instance($field_name, $bundle);
+      if (empty($instance['deleted'])) {
+        foreach ($field_type['property_callbacks'] as $callback) {
+          $callback($entity_info, $entity_type, $field, $instance, $field_type);
+        }
+      }
+    }
+  }
+}
+
+/**
+ * Callback to add in property info per field instance.
+ * @see field_entity_info_alter().
+ */
+function field_default_property_callback(&$entity_info, $entity_type, $field, $instance, $field_type) {
+  if (!empty($field_type['property_type'])) {
+    // Add in compatible formatters.
+    $formatters = array();
+    foreach (field_info_formatter_types() as $name => $formatter) {
+      if (in_array($field['type'], $formatter['field types'])) {
+        $formatters[$name] = array(
+          'label' => $formatter['label'],
+          'callback' => 'field_format_property',
+        );
+      }
+    }
+    $entity_info[$entity_type]['bundles'][$instance['bundle']]['properties'][$field['field_name']] = array(
+      'label' => $instance['label'],
+      'type' => $field_type['property_type'],
+      'description' => $instance['description'],
+      'getter callback' => 'field_property_get',
+      'setter callback' => 'field_property_set',
+      'formats' => $formatters,
+      'default format' => $field_type['default_formatter'],
+    );
+  }
+}
+
+/**
+ * Callback for applying custom property formats.
+ */
+function field_format_property($value, $formatter_type, $language, array $context) {
+  $field_name = $context['property'];
+  $langcode = _field_property_get_langcode($context['entity'], $language, $field_name);
+  $single = (field_behaviors_formatter('multiple values', $formatter_type) == FIELD_BEHAVIOR_DEFAULT);
+  $item = $single ? $context['entity']->{$field_name}[$langcode][0] : $context['entity']->$field_name;
+  $field = field_info_field($field_name);
+  return field_format($context['entity type'], $context['entity'], $field, $langcode, $item, $formatter_type);
+}
+
+/**
+ * Callback for getting field property values.
+ */
+function field_property_get($object, array $options, $name, $obj_type) {
+  $langcode = _field_property_get_langcode($object, $options['language'], $name);
+  return $options['sanitize'] ? $object->{$name}[$langcode][0]['safe'] : $object->{$name}[$langcode][0]['value'];
+}
+
+function _field_property_get_langcode($object, $language, $name) {
+  $langcode = FIELD_LANGUAGE_NONE;
+  if (isset($language) && isset($object->{$name}[$language->language])) {
+    $langcode = $language->language;
+  }
+  return $langcode;
+}
+
+/**
+ * Callback for setting field property values.
+ */
+function field_property_set(&$object, $name, $value, $obj_type) {
+  $langcode = array_shift(array_keys($object->$name));
+  $object->{$name}[$langcode][0]['value'] = $value;
+  unset($object->{$name}[$langcode][0]['safe']);
+
+  // Refresh the sanitized value too, so subsequent reads work right.
+  $field = field_info_field($name);
+  list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object);
+  $instance = field_info_instance($name, $bundle);
+
+  $function = $field['module'] . '_field_sanitize';
+  if (function_exists($function)) {
+    $function($obj_type, $object, $field, $instance, $langcode, $object->{$name}[$langcode]);
+  }
+}
+
+/**
  * @} End of "defgroup field_info"
  */
diff --git modules/field/field.module modules/field/field.module
index 4284961..6fe1aed 100644
--- modules/field/field.module
+++ modules/field/field.module
@@ -466,11 +466,6 @@ function _field_filter_xss_display_allowed_tags() {
 /**
  * Format a field item for display.
  *
- * TODO D7 : do we still need field_format ?
- * - backwards compatibility of templates - check what fallbacks we can propose...
- * - was used by Views integration in CCK in D6 - do we need now?
- * At least needs a little rehaul/update...
- *
  * Used to display a field's values outside the context of the $node, as
  * when fields are displayed in Views, or to display a field in a template
  * using a different formatter than the one set up on the Display Fields tab
@@ -478,6 +473,8 @@ function _field_filter_xss_display_allowed_tags() {
  *
  * @param $field
  *   Either a field array or the name of the field.
+ * @param $langcode
+ *   The language code of the formatted field items.
  * @param $item
  *   The field item(s) to be formatted (such as $node->field_foo[0],
  *   or $node->field_foo if the formatter handles multiple values itself)
@@ -492,7 +489,7 @@ function _field_filter_xss_display_allowed_tags() {
  *   It will have been passed through the necessary check_plain() or check_markup()
  *   functions as necessary.
  */
-function field_format($obj_type, $object, $field, $item, $formatter_type = NULL, $formatter_settings = array()) {
+function field_format($obj_type, $object, $field, $langcode, $item, $formatter_type = NULL, $formatter_settings = array()) {
   if (!is_array($field)) {
     $field = field_info_field($field);
   }
@@ -511,7 +508,7 @@ function field_format($obj_type, $object, $field, $item, $formatter_type = NULL,
     $display['settings'] += field_info_formatter_settings($display['type']);
 
     if ($display['type'] !== 'hidden') {
-      $theme = $formatter['module'] . '_formatter_' . $display['type'];
+      $theme = 'field_formatter_' . $display['type'];
 
       $element = array(
         '#theme' => $theme,
@@ -524,14 +521,14 @@ function field_format($obj_type, $object, $field, $item, $formatter_type = NULL,
         '#delta' => isset($item['#delta']) ? $item['#delta'] : NULL,
       );
 
-      if (field_behaviors_formatter('multiple values', $display) == FIELD_BEHAVIOR_DEFAULT) {
+      if (field_behaviors_formatter('multiple values', $display['type']) == FIELD_BEHAVIOR_DEFAULT) {
         // Single value formatter.
 
         // hook_field('sanitize') expects an array of items, so we build one.
         $items = array($item);
         $function = $field['module'] . '_field_sanitize';
         if (function_exists($function)) {
-          $function($obj_type, $object, $field, $instance, $items);
+          $function($obj_type, $object, $field, $instance, $langcode, $items);
         }
 
         $element['#item'] = $items[0];
@@ -541,7 +538,7 @@ function field_format($obj_type, $object, $field, $item, $formatter_type = NULL,
         $items = $item;
         $function = $field['module'] . '_field_sanitize';
         if (function_exists($function)) {
-          $function($obj_type, $object, $field, $instance, $items);
+          $function($obj_type, $object, $field, $instance, $langcode, $items);
         }
 
         foreach ($items as $delta => $item) {
diff --git modules/field/modules/number/number.module modules/field/modules/number/number.module
index c71a120..586c9c6 100644
--- modules/field/modules/number/number.module
+++ modules/field/modules/number/number.module
@@ -29,6 +29,7 @@ function number_field_info() {
       'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
       'default_widget' => 'number',
       'default_formatter' => 'number_integer',
+      'property_type' => 'integer',
     ),
     'number_decimal' => array(
       'label' => t('Decimal'),
@@ -37,6 +38,7 @@ function number_field_info() {
       'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
       'default_widget' => 'number',
       'default_formatter' => 'number_integer',
+      'property_type' => 'decimal',
     ),
     'number_float' => array(
       'label' => t('Float'),
@@ -44,6 +46,7 @@ function number_field_info() {
       'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
       'default_widget' => 'number',
       'default_formatter' => 'number_integer',
+      'property_type' => 'decimal',
     ),
   );
 }
diff --git modules/field/modules/text/text.module modules/field/modules/text/text.module
index 3d48b47..9c649ab 100644
--- modules/field/modules/text/text.module
+++ modules/field/modules/text/text.module
@@ -52,6 +52,7 @@ function text_field_info() {
       'instance_settings' => array('text_processing' => 0),
       'default_widget' => 'text_textfield',
       'default_formatter' => 'text_default',
+      'property_type' => 'text',
     ),
     'text_long' => array(
       'label' => t('Long text'),
@@ -60,6 +61,7 @@ function text_field_info() {
       'instance_settings' => array('text_processing' => 0),
       'default_widget' => 'text_textarea',
       'default_formatter' => 'text_default',
+      'property_type' => 'text',
     ),
     'text_with_summary' => array(
       'label' => t('Long text and summary'),
@@ -68,6 +70,8 @@ function text_field_info() {
       'instance_settings' => array('text_processing' => 1, 'display_summary' => 0),
       'default_widget' => 'text_textarea_with_summary',
       'default_formatter' => 'text_summary_or_trimmed',
+      'property_type' => 'text',
+      'property_callbacks' => array(),
     ),
   );
 }
diff --git modules/node/node.entity.inc modules/node/node.entity.inc
new file mode 100644
index 0000000..7d16b86
--- /dev/null
+++ modules/node/node.entity.inc
@@ -0,0 +1,120 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Provides info about the node entity.
+ */
+
+
+/**
+ * Implement hook_entity_info().
+ */
+function node_entity_info() {
+  $return = array(
+    'node' => array(
+      'label' => t('Node'),
+      'controller class' => 'NodeController',
+      'base table' => 'node',
+      'revision table' => 'node_revision',
+      'fieldable' => TRUE,
+      'object keys' => array(
+        'id' => 'nid',
+        'revision' => 'vid',
+        'bundle' => 'type',
+      ),
+      // Node.module handles its own caching.
+      // 'cacheable' => FALSE,
+      'bundles' => array(),
+    ),
+  );
+  // Bundles must provide a human readable name so we can create help and error
+  // messages, and the path to attach Field admin pages to.
+  foreach (node_type_get_names() as $type => $name) {
+    $return['node']['bundles'][$type] = array(
+      'label' => $name,
+      'admin' => array(
+        'path' => 'admin/structure/node-type/' . str_replace('_', '-', $type),
+        'access arguments' => array('administer content types'),
+      ),
+    );
+  }
+  // Add meta-data about the basic node properties.
+  $properties = &$return['node']['properties'];
+  
+  $properties['nid'] = array(
+    'label' => t("Node ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of the node."),
+  );
+  $properties['vid'] = array(
+    'label' => t("Revision ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of the node's latest revision."),
+  );
+  $properties['tnid'] = array(
+    'label' => t("Translation set ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of the original-language version of this node, if one exists."),
+  );
+  $properties['uid'] = array(
+    'label' => t("User ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of the author of the node."),
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['type'] = array(
+    'label' => t("Content type"),
+    'description' => t("The type of the node."),
+  );
+  $properties['type-name'] = array(
+    'label' => t("Content type name"),
+    'description' => t("The human-readable name of the node type."),
+    'getter callback' => 'node_get_properties',
+  );
+  $properties['title'] = array(
+    'label' => t("Title"),
+    'description' => t("The title of the node."),
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['language'] = array(
+    'label' => t("Language"),
+    'description' => t("The language the node is written in."),
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['url'] = array(
+    'label' => t("URL"),
+    'description' => t("The URL of the node."),
+    'getter callback' => 'node_get_properties',
+  );
+  $properties['edit-url'] = array(
+    'label' => t("Edit URL"),
+    'description' => t("The URL of the node's edit page."),
+    'getter callback' => 'node_get_properties',
+  );
+  $properties['created'] = array(
+    'label' => t("Date created"),
+    'type' => 'date',
+    'description' => t("The date the node was posted."),
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['changed'] = array(
+    'label' => t("Date changed"),
+    'type' => 'date',
+    'description' => t("The date the node was most recently updated."),
+  );
+  $properties['author-name'] = array(
+    'label' => t("Author name"),
+    'description' => t("The node author's name."),
+    'getter callback' => 'node_get_properties',
+  );
+  $properties['author'] = array(
+    'label' => t("Author"),
+    'type' => 'user',
+    'description' => t("The author of the node."),
+    'getter callback' => 'node_get_properties',
+  );
+
+  return $return;
+}
+
diff --git modules/node/node.info modules/node/node.info
index 6a690d2..0550c19 100644
--- modules/node/node.info
+++ modules/node/node.info
@@ -10,5 +10,5 @@ files[] = node.admin.inc
 files[] = node.pages.inc
 files[] = node.install
 files[] = node.test
-files[] = node.tokens.inc
+files[] = node.entity.inc
 required = TRUE
diff --git modules/node/node.module modules/node/node.module
index 19b2d9f..4cbaac4 100644
--- modules/node/node.module
+++ modules/node/node.module
@@ -147,42 +147,6 @@ function node_cron() {
 }
 
 /**
- * Implement hook_entity_info().
- */
-function node_entity_info() {
-  $return = array(
-    'node' => array(
-      'label' => t('Node'),
-      'controller class' => 'NodeController',
-      'base table' => 'node',
-      'revision table' => 'node_revision',
-      'fieldable' => TRUE,
-      'object keys' => array(
-        'id' => 'nid',
-        'revision' => 'vid',
-        'bundle' => 'type',
-      ),
-      // Node.module handles its own caching.
-      // 'cacheable' => FALSE,
-      'bundles' => array(),
-    ),
-  );
-  // Bundles must provide a human readable name so we can create help and error
-  // messages, and the path to attach Field admin pages to.
-  foreach (node_type_get_names() as $type => $name) {
-    $return['node']['bundles'][$type] = array(
-      'label' => $name,
-      'admin' => array(
-        'path' => 'admin/structure/node-type/' . str_replace('_', '-', $type),
-        'access arguments' => array('administer content types'),
-      ),
-    );
-  }
-  return $return;
-}
-
-
-/**
  * Implement hook_field_build_modes().
  */
 function node_field_build_modes($obj_type) {
@@ -3126,6 +3090,32 @@ function node_requirements($phase) {
 }
 
 /**
+ * Callback for getting node properties.
+ * @see node_entity_info().
+ */
+function node_get_properties($node, array $options, $name, $entity_type) {
+
+  switch ($name) {
+    case 'type-name':
+      $type_name = node_type_get_name($node->type);
+      return $options['sanitize'] ? check_plain($type_name) : $type_name;
+
+    case 'url':
+      return url('node/' . $node->nid, $options + array('absolute' => TRUE));
+
+    case 'edit-url':
+      return url('node/' . $node->nid . '/edit', $options + array('absolute' => TRUE));
+
+    case 'author-name':
+      $name = ($node->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $node->name;
+      return $options['sanitize'] ? filter_xss($name) : $name;
+      
+    case 'author':
+      return user_load($node->uid);
+  }
+}
+
+/**
  * Controller class for nodes.
  *
  * This extends the DrupalDefaultEntityController class, adding required
diff --git modules/system/system.module modules/system/system.module
index 5233131..6ba508b 100644
--- modules/system/system.module
+++ modules/system/system.module
@@ -3055,6 +3055,61 @@ function system_image_toolkits() {
 }
 
 /**
+ * Implementation of hook_token_format_info().
+ */
+function system_token_format_info() {
+  // Date related formats.
+  $date['short'] = array(
+    'label' => t("Short format"),
+    'description' => t("A date in 'short' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'short'))),
+    'callback' => 'system_format_date',
+  );
+  $date['medium'] = array(
+    'label' => t("Medium format"),
+    'description' => t("A date in 'medium' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'medium'))),
+    'callback' => 'system_format_date',
+  );
+  $date['long'] = array(
+    'label' => t("Long format"),
+    'description' => t("A date in 'long' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'long'))),
+    'callback' => 'system_format_date',
+  );
+  $date['since'] = array(
+    'label' => t("Time-since"),
+    'description' => t("A data in 'time-since' format. (%date)", array('%date' => format_interval(REQUEST_TIME - 360, 2))),
+    'callback' => 'system_format_date',
+  );
+  $date['raw'] = array(
+    'label' => t("Raw timestamp"),
+    'description' => t("A date in UNIX timestamp format (%date)", array('%date' => REQUEST_TIME)),
+    'callback' => 'system_format_date',
+  );
+  return array('date' => array('default format' => 'medium', 'formats' => $date));
+}
+
+/**
+ * Callback for formating date tokens.
+ * @see system_token_format_info().
+ */
+function system_format_date($date, $format, $language) {
+  $langcode = isset($language) ? $language->language : NULL;
+
+  switch ($format) {
+    case 'raw':
+      return filter_xss($date);
+
+    case 'short':
+    case 'medium':
+    case 'long':
+      return format_date($date, $format, '', NULL, $langcode);
+
+    case 'since':
+      return format_interval((REQUEST_TIME - $date), 2, $langcode);
+  }
+}
+
+
+/**
  * Attempts to get a file using drupal_http_request and to store it locally.
  *
  * @param $url
diff --git modules/system/system.test modules/system/system.test
index b7f224b..5bc2dc4 100644
--- modules/system/system.test
+++ modules/system/system.test
@@ -1081,6 +1081,142 @@ class SystemThemeFunctionalTest extends DrupalWebTestCase {
   }
 }
 
+/**
+ * Test entity propert wrapper.
+ */
+class DrupalPropertyWrapperTestCase extends DrupalWebTestCase {
+  public static function getInfo() {
+    return array(
+      'name' => 'Property wrappers',
+      'description' => 'Test using the drupal property wrappers.',
+      'group' => 'System',
+    );
+  }
+  
+  function setUp() {
+    parent::setUp('book');
+  }
+
+  /**
+   * Creates a user and a node, then tests getting the properties.
+   */
+  function testEntityPropertyWrapper() {
+    $account = $this->drupalCreateUser();
+    $node = $this->drupalCreateNode(array('uid' => $account->uid, 'title' => '<b>Is it bold?<b>'));
+    // For testing sanitizing give the user a malicious user name
+    $account = user_save($account, array('name' => '<b>BadName</b>'));
+
+    // Fetch the object so all properties are set as usual.
+    $node = node_load($node->nid);
+    
+    // First test without sanitizing.
+    $wrapper = drupal_get_property_wrapper('node', $node);
+    
+    $this->assertEqual($node->title, $wrapper->title, 'Getting property.');
+    $this->assertEqual($node->name, $wrapper->{'author-name'}, 'Getting property with getter callback.');
+    
+    // Test sanitized output.
+    $wrapper = drupal_get_property_wrapper('node', $node, array('sanitize' => TRUE));
+    
+    $this->assertEqual(check_plain($node->title), $wrapper->title, 'Getting sanitized property.');
+    $this->assertEqual(filter_xss($node->name), $wrapper->{'author-name'}, 'Getting sanitized property with getter callback.');
+    
+    // Test getting an not existing property
+    try {
+      echo $wrapper->dummy;
+      $this->fail('Getting an not existing property.');
+    }
+    catch (DrupalPropertyWrapperException $e) {
+      $this->pass('Getting an not existing property.');
+    }
+    
+    // Test setting.
+    $wrapper->title = 'test';
+    $this->assertEqual('test', $wrapper->title, 'Setting a property.');
+    try {
+      $wrapper->type = 'dummy';
+      $this->fail('Setting an unsupported property.');
+    }
+    catch (DrupalPropertyWrapperException $e) {
+      $this->pass('Setting an unsupported property.');
+    }
+    
+    // Test chaining
+    $this->assertEqual(check_plain($account->mail), $wrapper->author->mail, 'Testing chained usage.');
+    $this->assertEqual(filter_xss($account->name), $wrapper->author->name, 'Testing chained usage with callback and sanitizing.');
+    
+    // Test iterator
+    $type_info = entity_get_info('node');
+    $this->assertEqual(iterator_to_array($wrapper->getIterator()), array_keys($type_info['properties']), 'Iterator is working.');
+
+  }
+  
+  /**
+   * Tests the property format wrapper.
+   */
+  function testPropertyFormatWrapper() {
+    $date = REQUEST_TIME;
+    $wrapper = drupal_get_property_wrapper('date', $date);
+    
+    $this->assertEqual(format_date($date), (string)$wrapper, 'Apply the default format.');
+    $this->assertEqual(format_date($date, 'long'), $wrapper->long, 'Apply a certain format.');
+
+    // Test applying a not existing format.
+    try {
+      echo $wrapper->dummy;
+      $this->fail('Testing a not existing format.');
+    }
+    catch (DrupalPropertyWrapperException $e) {
+      $this->pass('Testing a not existing format.');
+    }
+    
+    // Test iterator
+    $info = token_get_format_info('date');
+    $this->assertEqual(iterator_to_array($wrapper->getIterator()), array_keys($info['formats']), 'Iterator is working.');
+  }
+  
+  /**
+   * Test field API support.
+   */
+  function testFieldPropertyWrappers() {
+    // Test the body field.
+    $body = array();
+    $body[FIELD_LANGUAGE_NONE][0] = array('value' => '<b>The body.</b>', 'summary' => 'The summary.');
+    $node = $this->drupalCreateNode(array('body' => $body));
+    // Fetch the object so all properties are set as usual.
+    $node = node_load($node->nid, array(), TRUE);
+    $wrapper = drupal_get_property_wrapper('node', $node, array('bundle' => $node->type, 'sanitize' => FALSE));
+    
+    $this->assertEqual('<b>The body.</b>', $wrapper->body->get(), 'Getting body property.');
+    $this->assertEqual("<p>The summary.</p>\n", $wrapper->body, "Default field formatter applied.");
+    $this->assertEqual("The body.\n", $wrapper->body->text_plain, "Specified field formatter applied.");
+
+    $wrapper->body = "<b>The second body.</b>";
+    $this->assertEqual("The second body.\n", $wrapper->body->text_plain, "Setting a field value and reading it again.");
+  }
+  
+  /**
+   * Test entity tags by using the 'book' tag of the book module.
+   */
+  function testTaggedEntities() {
+    $node = $this->drupalCreateNode(array('title' => "Book 1", 'type' => 'book'));
+    $node2 = $this->drupalCreateNode(array('title' => "Book page", 'type' => 'book', 'book' => array('bid' => $node->nid)));
+    // Fetch the object so all properties are set as usual.
+    $node = node_load($node->nid, array(), TRUE);
+    $node2 = node_load($node2->nid, array(), TRUE);
+    
+    // Test by specifing the bundle.
+    $wrapper = drupal_get_property_wrapper('node', $node2, array('bundle' => $node2->type));
+    $this->assertEqual("Book 1", $wrapper->book->title, "Specified bundle.");
+
+    // Test by specifing the tag directly.
+    $wrapper = drupal_get_property_wrapper('node', $node2, array('tags' => array('book')));
+    $this->assertEqual("Book 1", $wrapper->book->title, "Specified tag.");
+
+    // Make sure the book got taged as book too.
+    $this->assertEqual("Book 1", $wrapper->book->book->title, "Retrieved entity is tagged.");
+  }
+}
 
 /**
  * Test the basic queue functionality.
@@ -1198,6 +1334,7 @@ class TokenReplaceTestCase extends DrupalWebTestCase {
     $source  = '[node:title]';         // Title of the node we passed in
     $source .= '[node:author:name]';   // Node author's name
     $source .= '[node:created:since]'; // Time since the node was created
+    $source .= '[node:changed]';       // Last update time using the default format.
     $source .= '[current-user:name]';  // Current user's name
     $source .= '[user:name]';          // No user passed in, should be untouched
     $source .= '[date:short]';         // Short date format of REQUEST_TIME
@@ -1206,24 +1343,24 @@ class TokenReplaceTestCase extends DrupalWebTestCase {
     $target  = check_plain($node->title);
     $target .= check_plain($account->name);
     $target .= format_interval(REQUEST_TIME - $node->created, 2);
+    $target .= format_date($node->changed, 'medium');
     $target .= check_plain($user->name);
     $target .= '[user:name]';
     $target .= format_date(REQUEST_TIME, 'short');
     $target .= '[bogus:token]';
 
     $result = token_replace($source, array('node' => $node));
-
+    $this->assertFalse(strcmp($target, $result), t('Basic placeholder tokens replaced.'));
+    
     // Check that the results of token_generate are sanitized properly. This does NOT
     // test the cleanliness of every token -- just that the $sanitize flag is being
     // passed properly through the call stack and being handled correctly by a 'known'
     // token, [node:title].
-    $this->assertFalse(strcmp($target, $result), t('Basic placeholder tokens replaced.'));
-    
-    $raw_tokens = array('title' => '[node:title]');
-    $generated = token_generate('node', $raw_tokens, array('node' => $node));
+    $raw_tokens = array('node' => array('title' => '[node:title]'));
+    $generated = token_generate($raw_tokens, array('node' => $node));
     $this->assertFalse(strcmp($generated['[node:title]'], check_plain($node->title)), t('Token sanitized.'));
 
-    $generated = token_generate('node', $raw_tokens, array('node' => $node), array('sanitize' => FALSE));
+    $generated = token_generate($raw_tokens, array('node' => $node), array('sanitize' => FALSE));
     $this->assertFalse(strcmp($generated['[node:title]'], $node->title), t('Unsanitized token generated properly.'));
   }
 }
diff --git modules/user/user.entity.inc modules/user/user.entity.inc
new file mode 100644
index 0000000..f4d10e7
--- /dev/null
+++ modules/user/user.entity.inc
@@ -0,0 +1,74 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Provides info about the user entity.
+ */
+
+/**
+ * Implement hook_entity_info().
+ */
+function user_entity_info() {
+  $return = array(
+    'user' => array(
+      'label' => t('User'),
+      'controller class' => 'UserController',
+      'base table' => 'users',
+      'fieldable' => TRUE,
+      'object keys' => array(
+        'id' => 'uid',
+      ),
+      'bundles' => array(
+        'user' => array(
+          'label' => t('User'),
+          'admin' => array(
+            'path' => 'admin/config/people/accounts',
+            'access arguments' => array('administer users'),
+          ),
+        ),
+      ),
+    ),
+  );
+  // Add meta-data about the user properties.
+  $properties = &$return['user']['properties'];
+
+  $properties['uid'] = array(
+    'label' => t("User ID"),
+    'type' => 'integer',
+    'description' => t("The unique ID of the user account."),
+  );
+  $properties['name'] = array(
+    'label' => t("Name"),
+    'description' => t("The login name of the user account."),
+    'getter callback' => 'user_get_properties',
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['mail'] = array(
+    'label' => t("Email"),
+    'description' => t("The email address of the user account."),
+    'setter callback' => 'drupal_property_verbatim_set',
+  );
+  $properties['url'] = array(
+    'label' => t("URL"),
+    'description' => t("The URL of the account profile page."),
+    'getter callback' => 'user_get_properties',
+  );
+  $properties['edit-url'] = array(
+    'label' => t("Edit URL"),
+    'description' => t("The url of the account edit page."),
+    'getter callback' => 'user_get_properties',
+  );
+  $properties['login'] = array(
+    'label' => t("Last login"),
+    'description' => t("The date the user last logged in to the site."),
+    'type' => 'date',
+  );
+  $properties['created'] = array(
+    'label' => t("Created"),
+    'description' => t("The date the user account was created."),
+    'type' => 'date',
+  );
+  return $return;
+}
+
diff --git modules/user/user.info modules/user/user.info
index 54e288e..a828f42 100644
--- modules/user/user.info
+++ modules/user/user.info
@@ -9,5 +9,5 @@ files[] = user.admin.inc
 files[] = user.pages.inc
 files[] = user.install
 files[] = user.test
-files[] = user.tokens.inc
+files[] = user.entity.inc
 required = TRUE
diff --git modules/user/user.module modules/user/user.module
index 5f92858..070b64a 100644
--- modules/user/user.module
+++ modules/user/user.module
@@ -84,30 +84,21 @@ function user_theme() {
 }
 
 /**
- * Implement hook_entity_info().
+ * Callback for getting user properties.
+ * @see user_entity_info().
  */
-function user_entity_info() {
-  $return = array(
-    'user' => array(
-      'label' => t('User'),
-      'controller class' => 'UserController',
-      'base table' => 'users',
-      'fieldable' => TRUE,
-      'object keys' => array(
-        'id' => 'uid',
-      ),
-      'bundles' => array(
-        'user' => array(
-          'label' => t('User'),
-          'admin' => array(
-            'path' => 'admin/config/people/accounts',
-            'access arguments' => array('administer users'),
-          ),
-        ),
-      ),
-    ),
-  );
-  return $return;
+function user_get_properties($account, array $options, $name, $entity_type) {
+  switch ($name) {
+    case 'name':
+      $name = ($account->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $account->name;
+      return $options['sanitize'] ? filter_xss($name) : $name;
+
+    case 'url':
+      return url("user/$account->uid", $options + array('absolute' => TRUE));
+
+    case 'edit-url':
+      return url("user/$account->uid/edit", $options + array('absolute' => TRUE));
+  }
 }
 
 /**
