[ckan-changes] [okfn/ckan] 14457b: Merge branch 'master' of https://github.com/okfn/c...

GitHub noreply at github.com
Thu Apr 26 15:13:24 UTC 2012


  Branch: refs/heads/master
  Home:   https://github.com/okfn/ckan
  Commit: 14457b10fe3feee472faa01487e8aff1acb3838a
      https://github.com/okfn/ckan/commit/14457b10fe3feee472faa01487e8aff1acb3838a
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-25 (Wed, 25 Apr 2012)

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan




================================================================
  Commit: 05de104fb72c4c6c3dc8b2aef51ce8215a977332
      https://github.com/okfn/ckan/commit/05de104fb72c4c6c3dc8b2aef51ce8215a977332
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-26 (Thu, 26 Apr 2012)

  Changed paths:
    M ckan/__init__.py
    M ckan/config/middleware.py
    M ckan/config/routing.py
    M ckan/config/solr/CHANGELOG.txt
    A ckan/config/solr/schema-1.4.xml
    M ckan/controllers/package.py
    M ckan/lib/cli.py
    M ckan/lib/dictization/model_dictize.py
    M ckan/lib/dictization/model_save.py
    M ckan/lib/search/__init__.py
    M ckan/lib/search/index.py
    M ckan/logic/schema.py
    A ckan/migration/versions/057_tracking.py
    M ckan/model/__init__.py
    M ckan/model/package.py
    M ckan/model/resource.py
    A ckan/model/tracking.py
    M ckan/public/scripts/application.js
    M ckan/public/scripts/vendor/recline/css/data-explorer.css
    R ckan/public/scripts/vendor/recline/css/graph-flot.css
    A ckan/public/scripts/vendor/recline/css/graph.css
    A ckan/public/scripts/vendor/recline/css/grid.css
    M ckan/public/scripts/vendor/recline/css/map.css
    M ckan/public/scripts/vendor/recline/recline.js
    M ckan/templates/group/read.html
    M ckan/templates/layout_base.html
    M ckan/templates/package/layout.html
    A ckan/templates/package/resource_embedded_dataviewer.html
    M ckan/templates/package/resource_read.html
    M ckan/templates/package/search.html
    M ckan/templates/package/search_form.html
    A ckan/templates/snippets/data-viewer-embed-branded-link.html
    A ckan/templates/snippets/data-viewer-embed-dialog.html
    M ckan/tests/functional/test_pagination.py
    M ckan/tests/functional/test_search.py
    M ckan/tests/lib/test_dictization.py
    M ckan/tests/lib/test_resource_search.py
    M ckan/tests/lib/test_solr_package_search.py
    M ckanext/multilingual/solr/schema.xml
    M doc/index.rst
    M doc/using-data-api.rst
    M pip-requirements.txt
    M setup.py

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan


diff --git a/ckan/__init__.py b/ckan/__init__.py
index b433319..b1b1544 100644
--- a/ckan/__init__.py
+++ b/ckan/__init__.py
@@ -1,4 +1,4 @@
-__version__ = '1.6.1b'
+__version__ = '1.8a'
 __description__ = 'Comprehensive Knowledge Archive Network (CKAN) Software'
 __long_description__ = \
 '''CKAN software provides a hub for datasets. The flagship site running CKAN 
diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py
index c07948a..5d2be57 100644
--- a/ckan/config/middleware.py
+++ b/ckan/config/middleware.py
@@ -1,8 +1,11 @@
 """Pylons middleware initialization"""
 import urllib
-import logging 
+import urllib2
+import logging
 import json
+import hashlib
 
+import sqlalchemy as sa
 from beaker.middleware import CacheMiddleware, SessionMiddleware
 from paste.cascade import Cascade
 from paste.registry import RegistryManager
@@ -14,6 +17,7 @@
 from routes.middleware import RoutesMiddleware
 from repoze.who.config import WhoConfig
 from repoze.who.middleware import PluggableAuthenticationMiddleware
+
 from ckan.plugins import PluginImplementations
 from ckan.plugins.interfaces import IMiddleware
 from ckan.lib.i18n import get_locales
@@ -130,6 +134,8 @@ def make_app(global_conf, full_stack=True, static_files=True, **app_conf):
     if asbool(config.get('ckan.page_cache_enabled')):
         app = PageCacheMiddleware(app, config)
 
+    # Tracking add config option
+    app = TrackingMiddleware(app, config)
     return app
 
 class I18nMiddleware(object):
@@ -277,3 +283,40 @@ def _start_response(status, response_headers, exc_info=None):
             pipe.rpush(key, page_string)
             pipe.execute()
         return page
+
+
+class TrackingMiddleware(object):
+
+    def __init__(self, app, config):
+        self.app = app
+        self.engine = sa.create_engine(config.get('sqlalchemy.url'))
+
+
+    def __call__(self, environ, start_response):
+        path = environ['PATH_INFO']
+        if path == '/_tracking':
+            # do the tracking
+            # get the post data
+            payload = environ['wsgi.input'].read()
+            parts = payload.split('&')
+            data = {}
+            for part in parts:
+                k, v = part.split('=')
+                data[k] = urllib2.unquote(v).decode("utf8")
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            # we want a unique anonomized key for each user so that we do
+            # not count multiple clicks from the same user.
+            key = ''.join([
+                environ['HTTP_USER_AGENT'],
+                environ['REMOTE_ADDR'],
+                environ['HTTP_ACCEPT_LANGUAGE'],
+                environ['HTTP_ACCEPT_ENCODING'],
+            ])
+            key = hashlib.md5(key).hexdigest()
+            # store key/data here
+            sql = '''INSERT INTO tracking_raw
+                     (user_key, url, tracking_type)
+                     VALUES (%s, %s, %s)'''
+            self.engine.execute(sql, key, data.get('url'), data.get('type'))
+            return []
+        return self.app(environ, start_response)
diff --git a/ckan/config/routing.py b/ckan/config/routing.py
index d67727d..6c03cb0 100644
--- a/ckan/config/routing.py
+++ b/ckan/config/routing.py
@@ -189,6 +189,7 @@ def make_map():
         m.connect('/dataset/{id}.{format}', action='read')
         m.connect('/dataset/{id}', action='read')
         m.connect('/dataset/{id}/resource/{resource_id}', action='resource_read')
+        m.connect('/dataset/{id}/resource/{resource_id}/embed', action='resource_embedded_dataviewer')
 
     # group
     map.redirect('/groups', '/group')
diff --git a/ckan/config/solr/CHANGELOG.txt b/ckan/config/solr/CHANGELOG.txt
index 5fe664f..1e4e67f 100644
--- a/ckan/config/solr/CHANGELOG.txt
+++ b/ckan/config/solr/CHANGELOG.txt
@@ -1,6 +1,14 @@
 CKAN SOLR schemas changelog
 ===========================
 
+v1.4 - (ckan>=1.7)
+--------------------
+* Add Ascii folding filter to text fields.
+* Add capacity field for public, private access.
+* Add title_string so you can sort alphabetically on title.
+* Fields related to analytics, access and view counts.
+* Add data_dict field for the whole package_dict.
+
 v1.3 - (ckan>=1.5.1)
 --------------------
 * Use the index_id (hash of dataset id + site_id) as uniqueKey (#1430)
diff --git a/ckan/config/solr/schema-1.4.xml b/ckan/config/solr/schema-1.4.xml
new file mode 100644
index 0000000..0409e71
--- /dev/null
+++ b/ckan/config/solr/schema-1.4.xml
@@ -0,0 +1,180 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<schema name="ckan" version="1.4">
+
+<types>
+    <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
+    <fieldType name="boolean" class="solr.BoolField" sortMissingLast="true" omitNorms="true"/>
+    <fieldtype name="binary" class="solr.BinaryField"/>
+    <fieldType name="int" class="solr.TrieIntField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="float" class="solr.TrieFloatField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="long" class="solr.TrieLongField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="double" class="solr.TrieDoubleField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="tint" class="solr.TrieIntField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="tfloat" class="solr.TrieFloatField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="tlong" class="solr.TrieLongField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="tdouble" class="solr.TrieDoubleField" precisionStep="8" omitNorms="true" positionIncrementGap="0"/>
+    <fieldType name="date" class="solr.TrieDateField" omitNorms="true" precisionStep="0" positionIncrementGap="0"/>
+    <fieldType name="tdate" class="solr.TrieDateField" omitNorms="true" precisionStep="6" positionIncrementGap="0"/>
+
+    <fieldType name="text" class="solr.TextField" positionIncrementGap="100">
+        <analyzer type="index">
+            <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+            <!-- in this example, we will only use synonyms at query time
+            <filter class="solr.SynonymFilterFactory" synonyms="index_synonyms.txt" ignoreCase="true" expand="false"/>
+            -->
+            <!-- Case insensitive stop word removal.
+              add enablePositionIncrements=true in both the index and query
+              analyzers to leave a 'gap' for more accurate phrase queries.
+            -->
+            <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords.txt"
+                enablePositionIncrements="true"
+                />
+            <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="1"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+            <filter class="solr.SnowballPorterFilterFactory" language="English" protected="protwords.txt"/>
+            <filter class="solr.ASCIIFoldingFilterFactory"/>
+        </analyzer>
+        <analyzer type="query">
+            <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+            <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+            <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords.txt"
+                enablePositionIncrements="true"
+                />
+            <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange="1"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+            <filter class="solr.SnowballPorterFilterFactory" language="English" protected="protwords.txt"/>
+            <filter class="solr.ASCIIFoldingFilterFactory"/>
+        </analyzer>
+    </fieldType>
+
+
+    <!-- A general unstemmed text field - good if one does not know the language of the field -->
+    <fieldType name="textgen" class="solr.TextField" positionIncrementGap="100">
+        <analyzer type="index">
+            <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+            <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" enablePositionIncrements="true" />
+            <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange="0"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+        </analyzer>
+        <analyzer type="query">
+            <tokenizer class="solr.WhitespaceTokenizerFactory"/>
+            <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
+            <filter class="solr.StopFilterFactory"
+                ignoreCase="true"
+                words="stopwords.txt"
+                enablePositionIncrements="true"
+                />
+            <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange="0"/>
+            <filter class="solr.LowerCaseFilterFactory"/>
+        </analyzer>
+    </fieldType>
+</types>
+
+
+<fields>
+    <field name="index_id" type="string" indexed="true" stored="true" required="true" />
+    <field name="id" type="string" indexed="true" stored="true" required="true" />
+    <field name="site_id" type="string" indexed="true" stored="true" required="true" />
+    <field name="title" type="text" indexed="true" stored="true" />
+    <field name="entity_type" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="state" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="name" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="revision_id" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="version" type="string" indexed="true" stored="true" />
+    <field name="url" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="ckan_url" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="download_url" type="string" indexed="true" stored="true" omitNorms="true" />
+    <field name="notes" type="text" indexed="true" stored="true"/>
+    <field name="author" type="textgen" indexed="true" stored="true" />
+    <field name="author_email" type="textgen" indexed="true" stored="true" />
+    <field name="maintainer" type="textgen" indexed="true" stored="true" />
+    <field name="maintainer_email" type="textgen" indexed="true" stored="true" />
+    <field name="license" type="string" indexed="true" stored="true" />
+    <field name="license_id" type="string" indexed="true" stored="true" />
+    <field name="ratings_count" type="int" indexed="true" stored="false" />
+    <field name="ratings_average" type="float" indexed="true" stored="false" />
+    <field name="tags" type="string" indexed="true" stored="true" multiValued="true"/>
+    <field name="groups" type="string" indexed="true" stored="true" multiValued="true"/>
+
+    <field name="capacity" type="string" indexed="true" stored="true" multiValued="false"/>
+
+    <field name="res_description" type="textgen" indexed="true" stored="true" multiValued="true"/>
+    <field name="res_format" type="string" indexed="true" stored="true" multiValued="true"/>
+    <field name="res_url" type="string" indexed="true" stored="true" multiValued="true"/>
+
+    <!-- catchall field, containing all other searchable text fields (implemented
+         via copyField further on in this schema  -->
+    <field name="text" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="urls" type="text" indexed="true" stored="false" multiValued="true"/>
+
+    <field name="depends_on" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="dependency_of" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="derives_from" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="has_derivation" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="links_to" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="linked_from" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="child_of" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="parent_of" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="views_total" type="int" indexed="true" stored="false"/>
+    <field name="views_recent" type="int" indexed="true" stored="false"/>
+    <field name="resources_accessed_total" type="int" indexed="true" stored="false"/>
+    <field name="resources_accessed_recent" type="int" indexed="true" stored="false"/>
+
+    <field name="metadata_created" type="date" indexed="true" stored="true" multiValued="false"/>
+    <field name="metadata_modified" type="date" indexed="true" stored="true" multiValued="false"/>
+
+    <field name="indexed_ts" type="date" indexed="true" stored="true" default="NOW" multiValued="false"/>
+
+    <!-- Copy the title field into titleString, and treat as a string
+         (rather than text type).  This allows us to sort on the titleString -->
+    <field name="title_string" type="string" indexed="true" stored="false" />
+
+    <field name="data_dict" type="string" indexed="false" stored="true" />
+
+    <dynamicField name="extras_*" type="text" indexed="true" stored="true" multiValued="false"/>
+    <dynamicField name="*" type="string" indexed="true"  stored="false"/>
+</fields>
+
+<uniqueKey>index_id</uniqueKey>
+<defaultSearchField>text</defaultSearchField>
+<solrQueryParser defaultOperator="AND"/>
+
+<copyField source="url" dest="urls"/>
+<copyField source="ckan_url" dest="urls"/>
+<copyField source="download_url" dest="urls"/>
+<copyField source="res_url" dest="urls"/>
+<copyField source="extras_*" dest="text"/>
+<copyField source="urls" dest="text"/>
+<copyField source="name" dest="text"/>
+<copyField source="title" dest="text"/>
+<copyField source="text" dest="text"/>
+<copyField source="license" dest="text"/>
+<copyField source="notes" dest="text"/>
+<copyField source="tags" dest="text"/>
+<copyField source="groups" dest="text"/>
+<copyField source="res_description" dest="text"/>
+<copyField source="maintainer" dest="text"/>
+<copyField source="author" dest="text"/>
+
+</schema>
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index 37d5b31..ba12b86 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -751,3 +751,63 @@ def resource_read(self, id, resource_id):
                 qualified=True)
         return render('package/resource_read.html')
 
+    def resource_embedded_dataviewer(self, id, resource_id):
+        """
+        Embeded page for a read-only resource dataview.
+        """
+        context = {'model': model, 'session': model.Session,
+                   'user': c.user or c.author}
+
+        try:
+            c.resource = get_action('resource_show')(context, {'id': resource_id})
+            c.package = get_action('package_show')(context, {'id': id})
+            c.resource_json = json.dumps(c.resource)
+
+            # double check that the resource belongs to the specified package
+            if not c.resource['id'] in [ r['id'] for r in c.package['resources'] ]:
+                raise NotFound
+
+        except NotFound:
+            abort(404, _('Resource not found'))
+        except NotAuthorized:
+            abort(401, _('Unauthorized to read resource %s') % id)
+
+        # Construct the recline state
+        state_version = int(request.params.get('state_version', '1'))
+        recline_state = self._parse_recline_state(request.params)
+        if recline_state is None:
+            abort(400, ('"state" parameter must be a valid recline state (version %d)' % state_version))
+
+        c.recline_state = json.dumps(recline_state)
+
+        c.width = max(int(request.params.get('width', 500)), 100)
+        c.height = max(int(request.params.get('height', 500)), 100)
+        c.embedded = True
+
+        return render('package/resource_embedded_dataviewer.html')
+
+    def _parse_recline_state(self, params):
+        state_version = int(request.params.get('state_version', '1'))
+        if state_version != 1:
+            return None
+
+        recline_state = {}
+        for k,v in request.params.items():
+            try:
+                v = json.loads(v)
+            except ValueError:
+                pass
+            recline_state[k] = v
+
+        recline_state.pop('width', None)
+        recline_state.pop('height', None)
+        recline_state['readOnly'] = True
+
+        # Ensure only the currentView is available
+        if not recline_state.get('currentView', None):
+            recline_state['currentView'] = 'grid'   # default to grid view if none specified
+        for k in recline_state.keys():
+            if k.startswith('view-') and not k.endswith(recline_state['currentView']):
+                recline_state.pop(k)
+        return recline_state
+
diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py
index fd52cc6..d11a1f4 100644
--- a/ckan/lib/cli.py
+++ b/ckan/lib/cli.py
@@ -1,4 +1,5 @@
 import os
+import datetime
 import sys
 import logging
 from pprint import pprint
@@ -879,6 +880,117 @@ def clean(self, user_ratings=True):
             rating.purge()
         model.repo.commit_and_remove()
 
+class Tracking(CkanCommand):
+    '''Update tracking statistics
+
+    Usage:
+      tracking   - update tracking stats
+    '''
+
+    summary = __doc__.split('\n')[0]
+    usage = __doc__
+    max_args = 1
+    min_args = 0
+
+    def command(self):
+        self._load_config()
+        import ckan.model as model
+        engine = model.meta.engine
+
+        if len(self.args) == 1:
+            # Get summeries from specified date
+            start_date = datetime.datetime.strptime(self.args[0], '%Y-%m-%d')
+        else:
+            # No date given. See when we last have data for and get data
+            # from 2 days before then in case new data is available.
+            # If no date here then use 2010-01-01 as the start date
+            sql = '''SELECT tracking_date from tracking_summary
+                     ORDER BY tracking_date DESC LIMIT 1;'''
+            result = engine.execute(sql).fetchall()
+            if result:
+                start_date = result[0]['tracking_date']
+                start_date += datetime.timedelta(-2)
+                # convert date to datetime
+                combine = datetime.datetime.combine
+                start_date = combine(start_date, datetime.time(0))
+            else:
+                start_date = datetime.datetime(2011, 1, 1)
+        end_date = datetime.datetime.now()
+
+        while start_date < end_date:
+            stop_date = start_date + datetime.timedelta(1)
+            self.update_tracking(engine, start_date)
+            print 'tracking updated for %s' % start_date
+            start_date = stop_date
+
+    def update_tracking(self, engine, summary_date):
+        PACKAGE_URL = '/dataset/'
+        # clear out existing data before adding new
+        sql = '''DELETE FROM tracking_summary
+                 WHERE tracking_date='%s'; ''' % summary_date
+        engine.execute(sql)
+
+        sql = '''SELECT DISTINCT url, user_key,
+                     CAST(access_timestamp AS Date) AS tracking_date,
+                     tracking_type INTO tracking_tmp
+                 FROM tracking_raw
+                 WHERE CAST(access_timestamp as Date)='%s';
+
+                 INSERT INTO tracking_summary
+                   (url, count, tracking_date, tracking_type)
+                 SELECT url, count(user_key), tracking_date, tracking_type
+                 FROM tracking_tmp
+                 GROUP BY url, tracking_date, tracking_type;
+
+                 DROP TABLE tracking_tmp;
+                 COMMIT;''' % summary_date
+        engine.execute(sql)
+
+        # get ids for dataset urls
+        sql = '''UPDATE tracking_summary t
+                 SET package_id = COALESCE(
+                        (SELECT id FROM package p
+                        WHERE t.url =  %s || p.name)
+                     ,'~~not~found~~')
+                 WHERE t.package_id IS NULL
+                 AND tracking_type = 'page';'''
+        engine.execute(sql, PACKAGE_URL)
+
+        # update summary totals for resources
+        sql = '''UPDATE tracking_summary t1
+                 SET running_total = (
+                    SELECT sum(count)
+                    FROM tracking_summary t2
+                    WHERE t1.url = t2.url
+                    AND t2.tracking_date <= t1.tracking_date
+                 ) + t1.count
+                 ,recent_views = (
+                    SELECT sum(count)
+                    FROM tracking_summary t2
+                    WHERE t1.url = t2.url
+                    AND t2.tracking_date <= t1.tracking_date AND t2.tracking_date >= t1.tracking_date - 14
+                 ) + t1.count
+                 WHERE t1.running_total = 0 AND tracking_type = 'resource';'''
+        engine.execute(sql)
+
+        # update summary totals for pages
+        sql = '''UPDATE tracking_summary t1
+                 SET running_total = (
+                    SELECT sum(count)
+                    FROM tracking_summary t2
+                    WHERE t1.package_id = t2.package_id
+                    AND t2.tracking_date <= t1.tracking_date
+                 ) + t1.count
+                 ,recent_views = (
+                    SELECT sum(count)
+                    FROM tracking_summary t2
+                    WHERE t1.package_id = t2.package_id
+                    AND t2.tracking_date <= t1.tracking_date AND t2.tracking_date >= t1.tracking_date - 14
+                 ) + t1.count
+                 WHERE t1.running_total = 0 AND tracking_type = 'page'
+                 AND t1.package_id IS NOT NULL
+                 AND t1.package_id != '~~not~found~~';'''
+        engine.execute(sql)
 
 class PluginInfo(CkanCommand):
     ''' Provide info on installed plugins.
diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py
index ce48032..75fe85e 100644
--- a/ckan/lib/dictization/model_dictize.py
+++ b/ckan/lib/dictization/model_dictize.py
@@ -48,6 +48,7 @@ def resource_list_dictize(res_list, context):
         resource_dict = resource_dictize(res, context)
         if active and res.state not in ('active', 'pending'):
             continue
+
         result_list.append(resource_dict)
 
     return sorted(result_list, key=lambda x: x["position"])
@@ -95,6 +96,10 @@ def resource_dictize(res, context):
     extras = resource.pop("extras", None)
     if extras:
         resource.update(extras)
+    #tracking
+    model = context['model']
+    tracking = model.TrackingSummary.get_for_resource(res.url)
+    resource['tracking_summary'] = tracking
     return resource
 
 def related_dictize(rel, context):
@@ -192,6 +197,9 @@ def package_dictize(pkg, context):
     q = select([extra_rev]).where(extra_rev.c.package_id == pkg.id)
     result = _execute_with_revision(q, extra_rev, context)
     result_dict["extras"] = extras_list_dictize(result, context)
+    #tracking
+    tracking = model.TrackingSummary.get_for_package(pkg.id)
+    result_dict['tracking_summary'] = tracking
     #groups
     member_rev = model.member_revision_table
     group = model.group_table
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index fe585c4..8001dd4 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -26,7 +26,7 @@ def resource_dict_save(res_dict, context):
     for key, value in res_dict.iteritems():
         if isinstance(value, list):
             continue
-        if key in ('extras', 'revision_timestamp'):
+        if key in ('extras', 'revision_timestamp', 'tracking_summary'):
             continue
         if key in fields:
             if isinstance(getattr(obj, key), datetime.datetime):
diff --git a/ckan/lib/search/__init__.py b/ckan/lib/search/__init__.py
index fbb924a..b2774fc 100644
--- a/ckan/lib/search/__init__.py
+++ b/ckan/lib/search/__init__.py
@@ -26,7 +26,7 @@ def text_traceback():
 
 SIMPLE_SEARCH = config.get('ckan.simple_search', False)
 
-SUPPORTED_SCHEMA_VERSIONS = ['1.3']
+SUPPORTED_SCHEMA_VERSIONS = ['1.4']
 
 DEFAULT_OPTIONS = {
     'limit': 20,
diff --git a/ckan/lib/search/index.py b/ckan/lib/search/index.py
index 2599115..992721f 100644
--- a/ckan/lib/search/index.py
+++ b/ckan/lib/search/index.py
@@ -99,6 +99,11 @@ def index_package(self, pkg_dict):
         if pkg_dict is None:
             return
 
+        # add to string field for sorting
+        title = pkg_dict.get('title')
+        if title:
+            pkg_dict['title_string'] = title
+
         if (not pkg_dict.get('state')) or ('active' not in pkg_dict.get('state')):
             return self.delete_package(pkg_dict)
 
@@ -132,6 +137,12 @@ def index_package(self, pkg_dict):
 
         pkg_dict['groups'] = [group['name'] for group in groups]
 
+        # tracking
+        tracking_summary = pkg_dict.pop('tracking_summary', None)
+        if tracking_summary:
+            pkg_dict['views_total'] = tracking_summary['total']
+            pkg_dict['views_recent'] = tracking_summary['recent']
+
         # flatten the structure for indexing:
         for resource in pkg_dict.get('resources', []):
             for (okey, nkey) in [('description', 'res_description'),
@@ -157,7 +168,7 @@ def index_package(self, pkg_dict):
 
         pkg_dict = dict([(k.encode('ascii', 'ignore'), v) for (k, v) in pkg_dict.items()])
 
-        for k in ('title','notes'):
+        for k in ('title', 'notes', 'title_string'):
             if k in pkg_dict and pkg_dict[k]:
                 pkg_dict[k] = escape_xml_illegal_chars(pkg_dict[k])
 
diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py
index 72ec17a..09ffe22 100644
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -66,6 +66,7 @@ def default_resource_schema():
         'last_modified': [ignore_missing, isodate],
         'cache_last_updated': [ignore_missing, isodate],
         'webstore_last_updated': [ignore_missing, isodate],
+        'tracking_summary': [ignore],
         '__extras': [ignore_missing, extras_unicode_convert, keep_extras],
     }
 
diff --git a/ckan/migration/versions/057_tracking.py b/ckan/migration/versions/057_tracking.py
new file mode 100644
index 0000000..5f2fe43
--- /dev/null
+++ b/ckan/migration/versions/057_tracking.py
@@ -0,0 +1,33 @@
+from sqlalchemy import *
+from migrate import *
+
+def upgrade(migrate_engine):
+    migrate_engine.execute('''
+        BEGIN;
+        CREATE TABLE tracking_raw (
+            user_key character varying(100) NOT NULL,
+            url text NOT NULL,
+            tracking_type character varying(10) NOT NULL,
+            access_timestamp timestamp without time zone DEFAULT current_timestamp
+        );
+        CREATE INDEX tracking_raw_url ON tracking_raw(url);
+        CREATE INDEX tracking_raw_user_key ON tracking_raw(user_key);
+        CREATE INDEX tracking_raw_access_timestamp ON tracking_raw(access_timestamp);
+
+        CREATE TABLE tracking_summary(
+            url text NOT NULL,
+            package_id text,
+            tracking_type character varying(10) NOT NULL,
+            count int NOT NULL,
+            running_total int NOT NULL DEFAULT 0,
+            recent_views int NOT NULL DEFAULT 0,
+            tracking_date date
+        );
+
+        CREATE INDEX tracking_summary_url ON tracking_summary(url);
+        CREATE INDEX tracking_summary_package_id ON tracking_summary(package_id);
+        CREATE INDEX tracking_summary_date ON tracking_summary(tracking_date);
+
+        COMMIT;
+    '''
+    )
diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py
index 681e4ca..c49cb1b 100644
--- a/ckan/model/__init__.py
+++ b/ckan/model/__init__.py
@@ -22,6 +22,7 @@
 from authz import *
 from package_extra import *
 from resource import *
+from tracking import *
 from rating import *
 from package_relationship import *
 from task_status import *
diff --git a/ckan/model/package.py b/ckan/model/package.py
index dd1e0d3..0ad8106 100644
--- a/ckan/model/package.py
+++ b/ckan/model/package.py
@@ -171,6 +171,7 @@ def add_tag(self, tag):
             package_tag = model.PackageTag(self, tag)
             model.Session.add(package_tag)
 
+
     def add_tags(self, tags):
         for tag in tags:
             self.add_tag(tag)
@@ -274,6 +275,10 @@ def as_dict(self, ref_package_by='name', ref_group_by='name'):
         _dict['metadata_created'] = self.metadata_created.isoformat() \
             if self.metadata_created else None
         _dict['notes_rendered'] = ckan.misc.MarkdownFormat().to_html(self.notes)
+        #tracking
+        import ckan.model as model
+        tracking = model.TrackingSummary.get_for_package(self.id)
+        _dict['tracking_summary'] = tracking
         return _dict
 
     def add_relationship(self, type_, related_package, comment=u''):
@@ -562,6 +567,13 @@ def metadata_modified(self):
             timestamp_float = timegm(timestamp_without_usecs) + usecs
             return datetime.datetime.utcfromtimestamp(timestamp_float)
 
+    @property
+    def is_private(self):
+        """
+        A package is private if belongs to any private groups
+        """
+        return bool(self.get_groups(capacity='private'))
+
     def is_in_group(self, group):
         return group in self.get_groups()
 
diff --git a/ckan/model/resource.py b/ckan/model/resource.py
index f370e02..b1422db 100644
--- a/ckan/model/resource.py
+++ b/ckan/model/resource.py
@@ -109,6 +109,9 @@ def as_dict(self, core_columns_only=False):
             _dict[k] = v
         if self.resource_group and not core_columns_only:
             _dict["package_id"] = self.resource_group.package_id
+        import ckan.model as model
+        tracking = model.TrackingSummary.get_for_resource(self.url)
+        _dict['tracking_summary'] = tracking
         return _dict
 
     @classmethod
diff --git a/ckan/model/tracking.py b/ckan/model/tracking.py
new file mode 100644
index 0000000..384f84b
--- /dev/null
+++ b/ckan/model/tracking.py
@@ -0,0 +1,38 @@
+from meta import *
+from domain_object import DomainObject
+
+tracking_summary_table = Table('tracking_summary', metadata,
+        Column('url', UnicodeText, primary_key=True, nullable=False),
+        Column('package_id', UnicodeText),
+        Column('tracking_type', Unicode(10), nullable=False),
+        Column('count', Integer, nullable=False),
+        Column('running_total', Integer, nullable=False),
+        Column('recent_views', Integer, nullable=False),
+        Column('tracking_date', DateTime),
+    )
+
+class TrackingSummary(DomainObject):
+
+    @classmethod
+    def get_for_package(cls, package_id):
+        obj = Session.query(cls).autoflush(False)
+        obj = obj.filter_by(package_id=package_id)
+        data = obj.order_by('tracking_date desc').first()
+        if data:
+            return {'total' : data.running_total,
+                    'recent': data.recent_views}
+
+        return {'total' : 0, 'recent' : 0}
+
+
+    @classmethod
+    def get_for_resource(cls, url):
+        obj = Session.query(cls).autoflush(False)
+        data = obj.filter_by(url=url).order_by('tracking_date desc').first()
+        if data:
+            return {'total' : data.running_total,
+                    'recent': data.recent_views}
+
+        return {'total' : 0, 'recent' : 0}
+
+mapper(TrackingSummary, tracking_summary_table)
diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js
index 1c12a81..0d3628e 100644
--- a/ckan/public/scripts/application.js
+++ b/ckan/public/scripts/application.js
@@ -45,6 +45,12 @@ CKAN.Utils = CKAN.Utils || {};
     if (isResourceView) {
       CKAN.DataPreview.loadPreviewDialog(preload_resource);
     }
+
+    var isEmbededDataviewer = $('body.package.resource_embedded_dataviewer').length > 0;
+    if (isEmbededDataviewer) {
+      CKAN.DataPreview.loadEmbeddedPreview(preload_resource, reclineState);
+    }
+
     var isDatasetNew = $('body.package.new').length > 0;
     if (isDatasetNew) {
       // Set up magic URL slug editor
@@ -1344,6 +1350,81 @@ CKAN.DataPreview = function ($, my) {
   my.dialogId = 'ckanext-datapreview';
   my.$dialog = $('#' + my.dialogId);
 
+  // **Public: Loads a data previewer for an embedded page**
+  //
+  // Uses the provided reclineState to restore the Dataset.  Creates a single
+  // view for the Dataset (the one defined by reclineState.currentView).  And
+  // then passes the constructed Dataset, the constructed View, and the
+  // reclineState into the DataExplorer constructor.
+  my.loadEmbeddedPreview = function(resourceData, reclineState) {
+    my.$dialog.html('<h4>Loading ... <img src="http://assets.okfn.org/images/icons/ajaxload-circle.gif" class="loading-spinner" /></h4>');
+
+    // Restore the Dataset from the given reclineState.
+    var dataset = recline.Model.Dataset.restore(reclineState);
+
+    // Only create the view defined in reclineState.currentView.
+    // TODO: tidy this up.
+    var views = null;
+    if (reclineState.currentView === 'grid') {
+      views = [ {
+        id: 'grid',
+        label: 'Grid',
+        view: new recline.View.Grid({
+          model: dataset,
+          state: reclineState['view-grid']
+        })
+      }];
+    } else if (reclineState.currentView === 'graph') {
+      views = [ {
+        id: 'graph',
+        label: 'Graph',
+        view: new recline.View.Graph({
+          model: dataset,
+          state: reclineState['view-graph']
+        })
+      }];
+    } else if (reclineState.currentView === 'map') {
+      views = [ {
+        id: 'map',
+        label: 'Map',
+        view: new recline.View.Map({
+          model: dataset,
+          state: reclineState['view-map']
+        })
+      }];
+    }
+
+    // Finally, construct the DataExplorer.  Again, passing in the reclineState.
+    var dataExplorer = new recline.View.DataExplorer({
+      el: my.$dialog,
+      model: dataset,
+      state: reclineState,
+      views: views
+    });
+
+    Backbone.history.start();
+  };
+
+  // **Public: Creates a link to the embeddable page.
+  //
+  // For a given DataExplorer state, this function constructs and returns the
+  // url to the embeddable view of the current dataexplorer state.
+  my.makeEmbedLink = function(explorerState) {
+    var state = explorerState.toJSON();
+    state.state_version = 1;
+
+    var queryString = '?';
+    var items = [];
+    $.each(state, function(key, value) {
+      if (typeof(value) === 'object') {
+        value = JSON.stringify(value);
+      }
+      items.push(key + '=' + escape(value));
+    });
+    queryString += items.join('&');
+    return embedPath + queryString;
+  };
+
   // **Public: Loads a data preview**
   //
   // Fetches the preview data object from the link provided and loads the
@@ -1361,14 +1442,14 @@ CKAN.DataPreview = function ($, my) {
         {
           id: 'grid',
           label: 'Grid',
-          view: new recline.View.DataGrid({
+          view: new recline.View.Grid({
             model: dataset
           })
         },
         {
           id: 'graph',
           label: 'Graph',
-          view: new recline.View.FlotGraph({
+          view: new recline.View.Graph({
             model: dataset
           })
         },
@@ -1388,6 +1469,58 @@ CKAN.DataPreview = function ($, my) {
           readOnly: true
         }
       });
+
+      // -----------------------------
+      // Setup the Embed modal dialog.
+      // -----------------------------
+
+      // embedLink holds the url to the embeddable view of the current DataExplorer state.
+      var embedLink = $('.embedLink');
+
+      // embedIframeText contains the '<iframe>' construction, which sources
+      // the above link.
+      var embedIframeText = $('.embedIframeText');
+
+      // iframeWidth and iframeHeight control the width and height parameters
+      // used to construct the iframe, and are also used in the link.
+      var iframeWidth = $('.iframe-width');
+      var iframeHeight = $('.iframe-height');
+
+      // Update the embedLink and embedIframeText to contain the updated link
+      // and update width and height parameters.
+      function updateLink() {
+        var link = my.makeEmbedLink(dataExplorer.state);
+        var width = iframeWidth.val();
+        var height = iframeHeight.val();
+        link += '&width='+width+'&height='+height;
+
+        // Escape '"' characters in {{link}} in order not to prematurely close
+        // the src attribute value.
+        embedIframeText.val($.mustache('<iframe frameBorder="0" width="{{width}}" height="{{height}}" src="{{link}}"></iframe>',
+                                       {
+                                         link: link.replace(/"/g, '"'),
+                                         width: width,
+                                         height: height
+                                       }));
+        embedLink.attr('href', link);
+      }
+
+      // Bind changes to the DataExplorer, or the two width and height inputs
+      // to re-calculate the url.
+      dataExplorer.state.bind('change', updateLink);
+      for (var i=0; i<dataExplorer.pageViews.length; i++) {
+        dataExplorer.pageViews[i].view.state.bind('change', updateLink);
+      }
+
+      iframeWidth.change(updateLink);
+      iframeHeight.change(updateLink);
+
+      // Initial population of embedLink and embedIframeText
+      updateLink();
+
+      // Finally, since we have a DataExplorer, we can show the embed button.
+      $('.preview-header .btn').show();
+
       // will have to refactor if this can get called multiple times
       Backbone.history.start();
     }
diff --git a/ckan/public/scripts/vendor/recline/css/data-explorer.css b/ckan/public/scripts/vendor/recline/css/data-explorer.css
index 6cc5b0a..766a91e 100644
--- a/ckan/public/scripts/vendor/recline/css/data-explorer.css
+++ b/ckan/public/scripts/vendor/recline/css/data-explorer.css
@@ -78,320 +78,3 @@
   display: inline-block;
 }
 
-
-/**********************************************************
-  * Data Table
-  *********************************************************/
-
-.recline-grid .btn-group .dropdown-toggle {
-  padding: 1px 3px;
-  line-height: auto;
-}
-
-.recline-grid-container {
-  overflow: auto;
-  height: 550px;
-}
-
-.recline-grid {
-  border: 1px solid #ccc;
-  width: 100%;
-}
-
-.recline-grid td, .recline-grid th {
-  border-left: 1px solid #ccc;
-  padding: 3px 4px;
-  text-align: left;
-}
-
-.recline-grid tr td:first-child, .recline-grid tr th:first-child {
-  width: 20px;
-}
-
-/* direct borrowing from twitter buttons */
-.recline-grid th,
-.transform-column-view .expression-preview-table-wrapper th
-{
-  background-color: #e6e6e6;
-  background-repeat: no-repeat;
-  background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
-  background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
-  background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
-  background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
-  background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
-  background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
-  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);
-  text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
-  color: #333;
-  border: 1px solid #ccc;
-  border-bottom-color: #bbb;
-  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
-  -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
-  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
-  -webkit-transition: 0.1s linear all;
-  -moz-transition: 0.1s linear all;
-  -ms-transition: 0.1s linear all;
-  -o-transition: 0.1s linear all;
-  transition: 0.1s linear all;
-}
-
-
-/**********************************************************
-  * Data Table Menus
-  *********************************************************/
-
-.column-header-menu, a.root-header-menu {
-  float: right;
-}
-
-.read-only a.row-header-menu {
-  display: none;
-}
-
-div.data-table-cell-content {
-  line-height: 1.2;
-  color: #222;
-  position: relative;
-}
-
-div.data-table-cell-content-numeric {
-  text-align: right;
-}
-
-a.data-table-cell-edit {
-  position: absolute;
-  top: 0;
-  right: 0;
-  display: block;
-  width: 25px;
-  height: 16px;
-  text-decoration: none;
-  background-image: url(images/edit-map.png);
-  background-repeat: no-repeat;
-  visibility: hidden;
-}
-
-a.data-table-cell-edit:hover {
-  background-position: -25px 0px;
-}
-
-.recline-grid td:hover .data-table-cell-edit {
-  visibility: visible;
-}
-
-div.data-table-cell-content-numeric > a.data-table-cell-edit {
-  left: 0px;
-  right: auto;
-}
-
-.data-table-value-nonstring {
-  color: #282;
-}
-
-.data-table-error {
-  color: red;
-}
-
-.data-table-cell-editor-editor {
-  overflow: hidden;
-  display: block;
-  width: 98%;
-  height: 3em;
-  font-family: monospace;
-  margin: 3px 0;
-}
-
-.data-table-cell-copypaste-editor {
-  overflow: hidden;
-  display: block;
-  width: 98%;
-  height: 10em;
-  font-family: monospace;
-  margin: 3px 0;
-}
-
-.data-table-cell-editor-action {
-  float: left;
-  vertical-align: bottom;
-  text-align: center;
-}
-
-.data-table-cell-editor-key {
-  font-size: 0.8em;
-  color: #999;
-}
-
-
-/**********************************************************
-  * Dialogs
-  *********************************************************/
-
-.dialog-overlay {
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: #666;
-  opacity: 0.5;
-}
-
-.dialog {
-  position: fixed;
-  left: 0;
-  width: 100%;
-  text-align: center;
-}
-
-.dialog-frame {
-  margin: 0 auto;
-  text-align: left;
-  background: white;
-  border: 1px solid #3a5774;
-}
-
-.dialog-border {
-  border: 4px solid #c1d9ff;
-}
-
-.dialog-header {
-  background: #e0edfe;
-  padding: 10px;
-  font-weight: bold;
-  font-size: 1.6em;
-  color: #000;
-  cursor: move;
-}
-
-.dialog-body {
-  overflow: auto;
-  font-size: 1.3em;
-  padding: 15px;
-}
-
-.dialog-instruction {
-  padding: 0 0 7px;
-}
-
-.dialog-footer {
-  font-size: 1.3em;
-  background: #eee;
-  padding: 10px;
-}
-
-.dialog-busy {
-  width: 400px;
-  border: none;
-  -moz-border-radius: 5px;
-  -webkit-border-radius: 5px;
-  border-radius: 5px;
-}
-
-/**********************************************************
-  * Transform Dialog
-  *********************************************************/
-
-#expression-preview-tabs .ui-tabs-nav li a {
-  padding: 0.15em 1em;
-}
-
-textarea.expression-preview-code {
-  font-family: monospace;
-  height: 5em;
-  vertical-align: top;
-}
-
-.expression-preview-parsing-status {
-  color: #999;
-}
-
-.expression-preview-parsing-status.error {
-  color: red;
-}
-
-#expression-preview-tabs-preview,
-#expression-preview-tabs-help,
-#expression-preview-tabs-history,
-#expression-preview-tabs-starred {
-  padding: 5px;
-  overflow: hidden;
-}
-
-#expression-preview-tabs-preview > div,
-#expression-preview-tabs-help > div,
-#expression-preview-tabs-history > div,
-#expression-preview-tabs-starred {
-  height: 200px;
-  overflow: auto;
-}
-
-#expression-preview-tabs-preview td, #expression-preview-tabs-preview th,
-#expression-preview-tabs-help td, #expression-preview-tabs-help th,
-#expression-preview-tabs-history td, #expression-preview-tabs-history th,
-#expression-preview-tabs-starred td, #expression-preview-tabs-starred th {
-  padding: 5px;
-}
-
-.expression-preview-table-wrapper {
-  padding: 7px;
-}
-
-.expression-preview-container td {
-  padding: 2px 5px;
-  border-top: 1px solid #ccc;
-}
-
-td.expression-preview-heading {
-  border-top: none;
-  background: #ddd;
-  font-weight: bold;
-}
-
-td.expression-preview-value {
-  max-width: 250px !important;
-  overflow-x: hidden;
-}
-
-.expression-preview-special-value {
-  color: #aaa;
-}
-
-.expression-preview-help-container h3 {
-  margin-top: 15px;
-  margin-bottom: 7px;
-  border-bottom: 1px solid #999;
-}
-
-.expression-preview-doc-item-title {
-  font-weight: bold;
-  text-align: right;
-}
-
-.expression-preview-doc-item-params {
-}
-
-.expression-preview-doc-item-returns {
-}
-
-.expression-preview-doc-item-desc {
-  color: #666;
-}
-
-
-/**********************************************************
-  * Read-only mode
-  *********************************************************/
-
-.read-only .no-hidden .recline-grid tr td:first-child,
-.read-only .no-hidden .recline-grid tr th:first-child
-{
-  display: none;
-}
-
-
-.read-only .write-op,
-.read-only a.data-table-cell-edit
-{
-  display: none;
-}
-
diff --git a/ckan/public/scripts/vendor/recline/css/graph-flot.css b/ckan/public/scripts/vendor/recline/css/graph-flot.css
deleted file mode 100644
index d50f11e..0000000
--- a/ckan/public/scripts/vendor/recline/css/graph-flot.css
+++ /dev/null
@@ -1,50 +0,0 @@
-.data-graph-container .graph {
-  height: 500px;
-  margin-right: 200px;
-}
-
-.data-graph-container .legend table {
-  width: auto;
-  margin-bottom: 0;
-}
-
-.data-graph-container .legend td {
-  padding: 5px;
-  line-height: 13px;
-}
-
-/**********************************************************
-  * Editor
-  *********************************************************/
-
-.data-graph-container .editor {
-  float: right;
-  width: 200px;
-  padding-left: 0px;
-}
-
-.data-graph-container .editor-info {
-  padding-left: 4px;
-}
-
-.data-graph-container .editor-info {
-	cursor: pointer;
-}
-
-.data-graph-container .editor form {
-  padding-left: 4px;
-}
-
-.data-graph-container .editor select {
-	width: 100%;
-}
-
-.data-graph-container .editor-info {
-	border-bottom: 1px solid #ddd;
-	margin-bottom: 10px;
-}
-
-.data-graph-container .editor-hide-info p {
-	display: none;
-}
-
diff --git a/ckan/public/scripts/vendor/recline/css/graph.css b/ckan/public/scripts/vendor/recline/css/graph.css
new file mode 100644
index 0000000..413ac14
--- /dev/null
+++ b/ckan/public/scripts/vendor/recline/css/graph.css
@@ -0,0 +1,55 @@
+.recline-graph .graph {
+  height: 500px;
+  margin-right: 200px;
+}
+
+.recline-graph .legend table {
+  width: auto;
+  margin-bottom: 0;
+}
+
+.recline-graph .legend td {
+  padding: 5px;
+  line-height: 13px;
+}
+
+.recline-graph .graph .alert {
+  width: 450px;
+  margin: auto;
+}
+
+/**********************************************************
+  * Editor
+  *********************************************************/
+
+.recline-graph .editor {
+  float: right;
+  width: 200px;
+  padding-left: 0px;
+}
+
+.recline-graph .editor-info {
+  padding-left: 4px;
+}
+
+.recline-graph .editor-info {
+	cursor: pointer;
+}
+
+.recline-graph .editor form {
+  padding-left: 4px;
+}
+
+.recline-graph .editor select {
+	width: 100%;
+}
+
+.recline-graph .editor-info {
+	border-bottom: 1px solid #ddd;
+	margin-bottom: 10px;
+}
+
+.recline-graph .editor-hide-info p {
+	display: none;
+}
+
diff --git a/ckan/public/scripts/vendor/recline/css/grid.css b/ckan/public/scripts/vendor/recline/css/grid.css
new file mode 100644
index 0000000..aeb9984
--- /dev/null
+++ b/ckan/public/scripts/vendor/recline/css/grid.css
@@ -0,0 +1,319 @@
+/**********************************************************
+  * (Data) Grid
+  *********************************************************/
+
+.recline-grid .btn-group .dropdown-toggle {
+  padding: 1px 3px;
+  line-height: auto;
+}
+
+.recline-grid-container {
+  overflow: auto;
+  height: 550px;
+}
+
+.recline-grid {
+  border: 1px solid #ccc;
+  width: 100%;
+}
+
+.recline-grid td, .recline-grid th {
+  border-left: 1px solid #ccc;
+  padding: 3px 4px;
+  text-align: left;
+}
+
+.recline-grid td {
+  vertical-align: top;
+}
+
+.recline-grid tr td:first-child, .recline-grid tr th:first-child {
+  width: 20px;
+}
+
+/* direct borrowing from twitter buttons */
+.recline-grid th,
+.transform-column-view .expression-preview-table-wrapper th
+{
+  background-color: #e6e6e6;
+  background-repeat: no-repeat;
+  background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
+  background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+  background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
+  background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+  background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+  background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);
+  text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+  color: #333;
+  border: 1px solid #ccc;
+  border-bottom-color: #bbb;
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+  -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+  -webkit-transition: 0.1s linear all;
+  -moz-transition: 0.1s linear all;
+  -ms-transition: 0.1s linear all;
+  -o-transition: 0.1s linear all;
+  transition: 0.1s linear all;
+}
+
+
+/**********************************************************
+  * Data Table Menus
+  *********************************************************/
+
+.column-header-menu, a.root-header-menu {
+  float: right;
+}
+
+div.data-table-cell-content {
+  line-height: 1.2;
+  color: #222;
+  position: relative;
+}
+
+div.data-table-cell-content-numeric {
+  text-align: right;
+}
+
+a.data-table-cell-edit {
+  position: absolute;
+  top: 0;
+  right: 0;
+  display: block;
+  width: 25px;
+  height: 16px;
+  text-decoration: none;
+  background-image: url(images/edit-map.png);
+  background-repeat: no-repeat;
+  visibility: hidden;
+}
+
+a.data-table-cell-edit:hover {
+  background-position: -25px 0px;
+}
+
+.recline-grid td:hover .data-table-cell-edit {
+  visibility: visible;
+}
+
+div.data-table-cell-content-numeric > a.data-table-cell-edit {
+  left: 0px;
+  right: auto;
+}
+
+.data-table-value-nonstring {
+  color: #282;
+}
+
+.data-table-error {
+  color: red;
+}
+
+.data-table-cell-editor-editor {
+  overflow: hidden;
+  display: block;
+  width: 98%;
+  height: 3em;
+  font-family: monospace;
+  margin: 3px 0;
+}
+
+.data-table-cell-copypaste-editor {
+  overflow: hidden;
+  display: block;
+  width: 98%;
+  height: 10em;
+  font-family: monospace;
+  margin: 3px 0;
+}
+
+.data-table-cell-editor-action {
+  float: left;
+  vertical-align: bottom;
+  text-align: center;
+}
+
+.data-table-cell-editor-key {
+  font-size: 0.8em;
+  color: #999;
+}
+
+
+/**********************************************************
+  * Dialogs
+  *********************************************************/
+
+.dialog-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #666;
+  opacity: 0.5;
+}
+
+.dialog {
+  position: fixed;
+  left: 0;
+  width: 100%;
+  text-align: center;
+}
+
+.dialog-frame {
+  margin: 0 auto;
+  text-align: left;
+  background: white;
+  border: 1px solid #3a5774;
+}
+
+.dialog-border {
+  border: 4px solid #c1d9ff;
+}
+
+.dialog-header {
+  background: #e0edfe;
+  padding: 10px;
+  font-weight: bold;
+  font-size: 1.6em;
+  color: #000;
+  cursor: move;
+}
+
+.dialog-body {
+  overflow: auto;
+  font-size: 1.3em;
+  padding: 15px;
+}
+
+.dialog-instruction {
+  padding: 0 0 7px;
+}
+
+.dialog-footer {
+  font-size: 1.3em;
+  background: #eee;
+  padding: 10px;
+}
+
+.dialog-busy {
+  width: 400px;
+  border: none;
+  -moz-border-radius: 5px;
+  -webkit-border-radius: 5px;
+  border-radius: 5px;
+}
+
+/**********************************************************
+  * Transform Dialog
+  *********************************************************/
+
+#expression-preview-tabs .ui-tabs-nav li a {
+  padding: 0.15em 1em;
+}
+
+textarea.expression-preview-code {
+  font-family: monospace;
+  height: 5em;
+  vertical-align: top;
+}
+
+.expression-preview-parsing-status {
+  color: #999;
+}
+
+.expression-preview-parsing-status.error {
+  color: red;
+}
+
+#expression-preview-tabs-preview,
+#expression-preview-tabs-help,
+#expression-preview-tabs-history,
+#expression-preview-tabs-starred {
+  padding: 5px;
+  overflow: hidden;
+}
+
+#expression-preview-tabs-preview > div,
+#expression-preview-tabs-help > div,
+#expression-preview-tabs-history > div,
+#expression-preview-tabs-starred {
+  height: 200px;
+  overflow: auto;
+}
+
+#expression-preview-tabs-preview td, #expression-preview-tabs-preview th,
+#expression-preview-tabs-help td, #expression-preview-tabs-help th,
+#expression-preview-tabs-history td, #expression-preview-tabs-history th,
+#expression-preview-tabs-starred td, #expression-preview-tabs-starred th {
+  padding: 5px;
+}
+
+.expression-preview-table-wrapper {
+  padding: 7px;
+}
+
+.expression-preview-container td {
+  padding: 2px 5px;
+  border-top: 1px solid #ccc;
+}
+
+td.expression-preview-heading {
+  border-top: none;
+  background: #ddd;
+  font-weight: bold;
+}
+
+td.expression-preview-value {
+  max-width: 250px !important;
+  overflow-x: hidden;
+}
+
+.expression-preview-special-value {
+  color: #aaa;
+}
+
+.expression-preview-help-container h3 {
+  margin-top: 15px;
+  margin-bottom: 7px;
+  border-bottom: 1px solid #999;
+}
+
+.expression-preview-doc-item-title {
+  font-weight: bold;
+  text-align: right;
+}
+
+.expression-preview-doc-item-params {
+}
+
+.expression-preview-doc-item-returns {
+}
+
+.expression-preview-doc-item-desc {
+  color: #666;
+}
+
+
+/**********************************************************
+  * Read-only mode
+  *********************************************************/
+
+.recline-read-only .no-hidden .recline-grid tr td:first-child,
+.recline-read-only .no-hidden .recline-grid tr th:first-child
+{
+  display: none;
+}
+
+.recline-read-only .recline-grid .write-op,
+.recline-read-only .recline-grid a.data-table-cell-edit
+{
+  display: none;
+}
+
+.recline-read-only a.row-header-menu {
+  display: none;
+}
+
diff --git a/ckan/public/scripts/vendor/recline/css/map.css b/ckan/public/scripts/vendor/recline/css/map.css
index c8adde7..f1f2da2 100644
--- a/ckan/public/scripts/vendor/recline/css/map.css
+++ b/ckan/public/scripts/vendor/recline/css/map.css
@@ -1,4 +1,4 @@
-.data-map-container .map {
+.recline-map .map {
   height: 500px;
 }
 
@@ -6,18 +6,23 @@
   * Editor
   *********************************************************/
 
-.data-map-container .editor {
+.recline-map .editor {
   float: right;
   width: 200px;
   padding-left: 0px;
   margin-left: 10px;
 }
 
-.data-map-container .editor form {
+.recline-map .editor form {
   padding-left: 4px;
 }
 
-.data-map-container .editor select {
-	width: 100%;
+.recline-map .editor select {
+  width: 100%;
 }
 
+.recline-map .editor .editor-options {
+  margin-top: 10px;
+  border-top: 1px solid gray;
+  padding: 5px 0;
+}
diff --git a/ckan/public/scripts/vendor/recline/recline.js b/ckan/public/scripts/vendor/recline/recline.js
index 2871c6d..271e9c5 100644
--- a/ckan/public/scripts/vendor/recline/recline.js
+++ b/ckan/public/scripts/vendor/recline/recline.js
@@ -86,7 +86,7 @@ this.recline.Model = this.recline.Model || {};
 //
 // @property {number} docCount: total number of documents in this dataset
 //
-// @property {Backend} backend: the Backend (instance) for this Dataset
+// @property {Backend} backend: the Backend (instance) for this Dataset.
 //
 // @property {Query} queryState: `Query` object which stores current
 // queryState. queryState may be edited by other components (e.g. a query
@@ -96,14 +96,24 @@ this.recline.Model = this.recline.Model || {};
 // Facets.
 my.Dataset = Backbone.Model.extend({
   __type__: 'Dataset',
+
   // ### initialize
   // 
   // Sets up instance properties (see above)
+  //
+  // @param {Object} model: standard set of model attributes passed to Backbone models
+  //
+  // @param {Object or String} backend: Backend instance (see
+  // `recline.Backend.Base`) or a string specifying that instance. The
+  // string specifying may be a full class path e.g.
+  // 'recline.Backend.ElasticSearch' or a simple name e.g.
+  // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in
+  // recline.Backend module)
   initialize: function(model, backend) {
     _.bindAll(this, 'query');
     this.backend = backend;
-    if (backend && backend.constructor == String) {
-      this.backend = my.backends[backend];
+    if (typeof(backend) === 'string') {
+      this.backend = this._backendFromString(backend);
     }
     this.fields = new my.FieldList();
     this.currentDocuments = new my.DocumentList();
@@ -167,9 +177,73 @@ my.Dataset = Backbone.Model.extend({
     data.docCount = this.docCount;
     data.fields = this.fields.toJSON();
     return data;
+  },
+
+  // ### _backendFromString(backendString)
+  //
+  // See backend argument to initialize for details
+  _backendFromString: function(backendString) {
+    var parts = backendString.split('.');
+    // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class
+    var current = window;
+    for(ii=0;ii<parts.length;ii++) {
+      if (!current) {
+        break;
+      }
+      current = current[parts[ii]];
+    }
+    if (current) {
+      return new current();
+    }
+
+    // alternatively we just had a simple string
+    var backend = null;
+    if (recline && recline.Backend) {
+      _.each(_.keys(recline.Backend), function(name) {
+        if (name.toLowerCase() === backendString.toLowerCase()) {
+          backend = new recline.Backend[name]();
+        }
+      });
+    }
+    return backend;
   }
 });
 
+
+// ### Dataset.restore
+//
+// Restore a Dataset instance from a serialized state. Serialized state for a
+// Dataset is an Object like:
+// 
+// <pre>
+// {
+//   backend: {backend type - i.e. value of dataset.backend.__type__}
+//   dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler }
+//   // convenience - if url provided and dataste not this be used as dataset url
+//   url: {dataset url}
+//   ...
+// }
+my.Dataset.restore = function(state) {
+  // hack-y - restoring a memory dataset does not mean much ...
+  var dataset = null;
+  if (state.url && !state.dataset) {
+    state.dataset = {url: state.url};
+  }
+  if (state.backend === 'memory') {
+    dataset = recline.Backend.createDataset(
+      [{stub: 'this is a stub dataset because we do not restore memory datasets'}],
+      [],
+      state.dataset // metadata
+    );
+  } else {
+    dataset = new recline.Model.Dataset(
+      state.dataset,
+      state.backend
+    );
+  }
+  return dataset;
+};
+
 // ## <a id="document">A Document (aka Row)</a>
 // 
 // A single entry or row in the dataset
@@ -211,7 +285,8 @@ my.DocumentList = Backbone.Collection.extend({
 // * format: (optional) used to indicate how the data should be formatted. For example:
 //   * type=date, format=yyyy-mm-dd
 //   * type=float, format=percentage
-//   * type=float, format='###,###.##'
+//   * type=string, format=link (render as hyperlink)
+//   * type=string, format=markdown (render as markdown if Showdown available)
 // * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
 // 
 // Following additional instance properties:
@@ -267,6 +342,22 @@ my.Field = Backbone.Model.extend({
       if (format === 'percentage') {
         return val + '%';
       }
+      return val;
+    },
+    'string': function(val, field, doc) {
+      var format = field.get('format');
+      if (format === 'link') {
+        return '<a href="VAL">VAL</a>'.replace(/VAL/g, val);
+      } else if (format === 'markdown') {
+        if (typeof Showdown !== 'undefined') {
+          var showdown = new Showdown.converter();
+          out = showdown.makeHtml(val);
+          return out;
+        } else {
+          return val;
+        }
+      }
+      return val;
     }
   }
 });
@@ -449,6 +540,13 @@ my.FacetList = Backbone.Collection.extend({
   model: my.Facet
 });
 
+// ## Object State
+//
+// Convenience Backbone model for storing (configuration) state of objects like Views.
+my.ObjectState = Backbone.Model.extend({
+});
+
+
 // ## Backend registry
 //
 // Backends will register themselves by id into this registry
@@ -618,10 +716,10 @@ this.recline.View = this.recline.View || {};
 
 // ## Graph view for a Dataset using Flot graphing library.
 //
-// Initialization arguments:
+// Initialization arguments (in a hash in first parameter):
 //
 // * model: recline.Model.Dataset
-// * config: (optional) graph configuration hash of form:
+// * state: (optional) configuration hash of form:
 //
 //        { 
 //          group: {column name for x-axis},
@@ -631,10 +729,10 @@ this.recline.View = this.recline.View || {};
 //
 // NB: should *not* provide an el argument to the view but must let the view
 // generate the element itself (you can then append view.el to the DOM.
-my.FlotGraph = Backbone.View.extend({
+my.Graph = Backbone.View.extend({
 
   tagName:  "div",
-  className: "data-graph-container",
+  className: "recline-graph",
 
   template: ' \
   <div class="editor"> \
@@ -659,22 +757,13 @@ my.FlotGraph = Backbone.View.extend({
         <label>Group Column (x-axis)</label> \
         <div class="input editor-group"> \
           <select> \
+          <option value="">Please choose ...</option> \
           {{#fields}} \
           <option value="{{id}}">{{label}}</option> \
           {{/fields}} \
           </select> \
         </div> \
         <div class="editor-series-group"> \
-          <div class="editor-series"> \
-            <label>Series <span>A (y-axis)</span></label> \
-            <div class="input"> \
-              <select> \
-              {{#fields}} \
-              <option value="{{id}}">{{label}}</option> \
-              {{/fields}} \
-              </select> \
-            </div> \
-          </div> \
         </div> \
       </div> \
       <div class="editor-buttons"> \
@@ -686,18 +775,39 @@ my.FlotGraph = Backbone.View.extend({
       </div> \
     </form> \
   </div> \
-  <div class="panel graph"></div> \
+  <div class="panel graph"> \
+    <div class="js-temp-notice alert alert-block"> \
+      <h3 class="alert-heading">Hey there!</h3> \
+      <p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \
+      <p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \
+    </div> \
+  </div> \
 </div> \
 ',
+  templateSeriesEditor: ' \
+    <div class="editor-series js-series-{{seriesIndex}}"> \
+      <label>Series <span>{{seriesName}} (y-axis)</span> \
+        [<a href="#remove" class="action-remove-series">Remove</a>] \
+      </label> \
+      <div class="input"> \
+        <select> \
+        <option value="">Please choose ...</option> \
+        {{#fields}} \
+        <option value="{{id}}">{{label}}</option> \
+        {{/fields}} \
+        </select> \
+      </div> \
+    </div> \
+  ',
 
   events: {
     'change form select': 'onEditorSubmit',
-    'click .editor-add': 'addSeries',
+    'click .editor-add': '_onAddSeries',
     'click .action-remove-series': 'removeSeries',
     'click .action-toggle-help': 'toggleHelp'
   },
 
-  initialize: function(options, config) {
+  initialize: function(options) {
     var self = this;
     this.el = $(this.el);
     _.bindAll(this, 'render', 'redraw');
@@ -707,46 +817,66 @@ my.FlotGraph = Backbone.View.extend({
     this.model.fields.bind('add', this.render);
     this.model.currentDocuments.bind('add', this.redraw);
     this.model.currentDocuments.bind('reset', this.redraw);
-    var configFromHash = my.parseHashQueryString().graph;
-    if (configFromHash) {
-      configFromHash = JSON.parse(configFromHash);
-    }
-    this.chartConfig = _.extend({
+    var stateData = _.extend({
         group: null,
-        series: [],
+        // so that at least one series chooser box shows up
+        series: [""],
         graphType: 'lines-and-points'
       },
-      configFromHash,
-      config
-      );
+      options.state
+    );
+    this.state = new recline.Model.ObjectState(stateData);
     this.render();
   },
 
   render: function() {
-    htmls = $.mustache(this.template, this.model.toTemplateJSON());
+    var self = this;
+    var tmplData = this.model.toTemplateJSON();
+    var htmls = $.mustache(this.template, tmplData);
     $(this.el).html(htmls);
-    // now set a load of stuff up
     this.$graph = this.el.find('.panel.graph');
-    // for use later when adding additional series
-    // could be simpler just to have a common template!
-    this.$seriesClone = this.el.find('.editor-series').clone();
-    this._updateSeries();
+
+    // set up editor from state
+    if (this.state.get('graphType')) {
+      this._selectOption('.editor-type', this.state.get('graphType'));
+    }
+    if (this.state.get('group')) {
+      this._selectOption('.editor-group', this.state.get('group'));
+    }
+    _.each(this.state.get('series'), function(series, idx) {
+      self.addSeries(idx);
+      self._selectOption('.editor-series.js-series-' + idx, series);
+    });
     return this;
   },
 
+  // Private: Helper function to select an option from a select list
+  //
+  _selectOption: function(id,value){
+    var options = this.el.find(id + ' select > option');
+    if (options) {
+      options.each(function(opt){
+        if (this.value == value) {
+          $(this).attr('selected','selected');
+          return false;
+        }
+      });
+    }
+  },
+
   onEditorSubmit: function(e) {
     var select = this.el.find('.editor-group select');
-    $editor = this;
-    var series = this.$series.map(function () {
+    var $editor = this;
+    var $series  = this.el.find('.editor-series select');
+    var series = $series.map(function () {
       return $(this).val();
     });
-    this.chartConfig.series = $.makeArray(series);
-    this.chartConfig.group = this.el.find('.editor-group select').val();
-    this.chartConfig.graphType = this.el.find('.editor-type select').val();
-    // update navigation
-    var qs = my.parseHashQueryString();
-    qs.graph = JSON.stringify(this.chartConfig);
-    my.setHashQueryString(qs);
+    var updatedState = {
+      series: $.makeArray(series),
+      group: this.el.find('.editor-group select').val(),
+      graphType: this.el.find('.editor-type select').val()
+    };
+    this.state.set(updatedState);
     this.redraw();
   },
 
@@ -762,7 +892,7 @@ my.FlotGraph = Backbone.View.extend({
       return;
     }
     var series = this.createSeries();
-    var options = this.getGraphOptions(this.chartConfig.graphType);
+    var options = this.getGraphOptions(this.state.attributes.graphType);
     this.plot = $.plot(this.$graph, series, options);
     this.setupTooltips();
     // create this.plot and cache it
@@ -777,13 +907,23 @@ my.FlotGraph = Backbone.View.extend({
 //    }
   },
 
+  // ### getGraphOptions
+  //
+  // Get options for Flot Graph
+  //
   // needs to be function as can depend on state
+  //
+  // @param typeId graphType id (lines, lines-and-points etc)
   getGraphOptions: function(typeId) { 
     var self = this;
     // special tickformatter to show labels rather than numbers
+    // TODO: we should really use tickFormatter and 1 interval ticks if (and
+    // only if) x-axis values are non-numeric
+    // However, that is non-trivial to work out from a dataset (datasets may
+    // have no field type info). Thus at present we only do this for bars.
     var tickFormatter = function (val) {
       if (self.model.currentDocuments.models[val]) {
-        var out = self.model.currentDocuments.models[val].get(self.chartConfig.group);
+        var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);
         // if the value was in fact a number we want that not the 
         if (typeof(out) == 'number') {
           return val;
@@ -793,20 +933,25 @@ my.FlotGraph = Backbone.View.extend({
       }
       return val;
     };
-    // TODO: we should really use tickFormatter and 1 interval ticks if (and
-    // only if) x-axis values are non-numeric
-    // However, that is non-trivial to work out from a dataset (datasets may
-    // have no field type info). Thus at present we only do this for bars.
-    var options = { 
+
+    var xaxis = {};
+    // check for time series on x-axis
+    if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
+      xaxis.mode = 'time';
+      xaxis.timeformat = '%y-%b';
+    }
+    var optionsPerGraphType = { 
       lines: {
-         series: { 
-           lines: { show: true }
-         }
+        series: { 
+          lines: { show: true }
+        },
+        xaxis: xaxis
       },
       points: {
         series: {
           points: { show: true }
         },
+        xaxis: xaxis,
         grid: { hoverable: true, clickable: true }
       },
       'lines-and-points': {
@@ -814,6 +959,7 @@ my.FlotGraph = Backbone.View.extend({
           points: { show: true },
           lines: { show: true }
         },
+        xaxis: xaxis,
         grid: { hoverable: true, clickable: true }
       },
       bars: {
@@ -837,7 +983,7 @@ my.FlotGraph = Backbone.View.extend({
         }
       }
     };
-    return options[typeId];
+    return optionsPerGraphType[typeId];
   },
 
   setupTooltips: function() {
@@ -866,14 +1012,14 @@ my.FlotGraph = Backbone.View.extend({
           var y = item.datapoint[1];
           // convert back from 'index' value on x-axis (e.g. in cases where non-number values)
           if (self.model.currentDocuments.models[x]) {
-            x = self.model.currentDocuments.models[x].get(self.chartConfig.group);
+            x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
           } else {
             x = x.toFixed(2);
           }
           y = y.toFixed(2);
           
           var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
-            group: self.chartConfig.group,
+            group: self.state.attributes.group,
             x: x,
             series: item.series.label,
             y: y
@@ -891,47 +1037,54 @@ my.FlotGraph = Backbone.View.extend({
   createSeries: function () {
     var self = this;
     var series = [];
-    if (this.chartConfig) {
-      $.each(this.chartConfig.series, function (seriesIndex, field) {
-        var points = [];
-        $.each(self.model.currentDocuments.models, function (index, doc) {
-          var x = doc.get(self.chartConfig.group);
-          var y = doc.get(field);
-          if (typeof x === 'string') {
-            x = index;
-          }
-          // horizontal bar chart
-          if (self.chartConfig.graphType == 'bars') {
-            points.push([y, x]);
-          } else {
-            points.push([x, y]);
-          }
-        });
-        series.push({data: points, label: field});
+    _.each(this.state.attributes.series, function(field) {
+      var points = [];
+      _.each(self.model.currentDocuments.models, function(doc, index) {
+        var xfield = self.model.fields.get(self.state.attributes.group);
+        var x = doc.getFieldValue(xfield);
+        // time series
+        var isDateTime = xfield.get('type') === 'date';
+        if (isDateTime) {
+          x = new Date(x);
+        }
+        var yfield = self.model.fields.get(field);
+        var y = doc.getFieldValue(yfield);
+        if (typeof x === 'string') {
+          x = index;
+        }
+        // horizontal bar chart
+        if (self.state.attributes.graphType == 'bars') {
+          points.push([y, x]);
+        } else {
+          points.push([x, y]);
+        }
       });
-    }
+      series.push({data: points, label: field});
+    });
     return series;
   },
 
   // Public: Adds a new empty series select box to the editor.
   //
-  // All but the first select box will have a remove button that allows them
-  // to be removed.
+  // @param [int] idx index of this series in the list of series
   //
   // Returns itself.
-  addSeries: function (e) {
-    e.preventDefault();
-    var element = this.$seriesClone.clone(),
-        label   = element.find('label'),
-        index   = this.$series.length;
-
-    this.el.find('.editor-series-group').append(element);
-    this._updateSeries();
-    label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
-    label.find('span').text(String.fromCharCode(this.$series.length + 64));
+  addSeries: function (idx) {
+    var data = _.extend({
+      seriesIndex: idx,
+      seriesName: String.fromCharCode(idx + 64 + 1),
+    }, this.model.toTemplateJSON());
+
+    var htmls = $.mustache(this.templateSeriesEditor, data);
+    this.el.find('.editor-series-group').append(htmls);
     return this;
   },
 
+  _onAddSeries: function(e) {
+    e.preventDefault();
+    this.addSeries(this.state.get('series').length);
+  },
+
   // Public: Removes a series list item from the editor.
   //
   // Also updates the labels of the remaining series elements.
@@ -939,26 +1092,12 @@ my.FlotGraph = Backbone.View.extend({
     e.preventDefault();
     var $el = $(e.target);
     $el.parent().parent().remove();
-    this._updateSeries();
-    this.$series.each(function (index) {
-      if (index > 0) {
-        var labelSpan = $(this).prev().find('span');
-        labelSpan.text(String.fromCharCode(index + 65));
-      }
-    });
     this.onEditorSubmit();
   },
 
   toggleHelp: function() {
     this.el.find('.editor-info').toggleClass('editor-hide-info');
   },
-
-  // Private: Resets the series property to reference the select elements.
-  //
-  // Returns itself.
-  _updateSeries: function () {
-    this.$series  = this.el.find('.editor-series select');
-  }
 });
 
 })(jQuery, recline.View);
@@ -969,12 +1108,12 @@ this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
 
 (function($, my) {
-// ## DataGrid
+// ## (Data) Grid Dataset View
 //
 // Provides a tabular view on a Dataset.
 //
 // Initialize it with a `recline.Model.Dataset`.
-my.DataGrid = Backbone.View.extend({
+my.Grid = Backbone.View.extend({
   tagName:  "div",
   className: "recline-grid-container",
 
@@ -985,12 +1124,16 @@ my.DataGrid = Backbone.View.extend({
     this.model.currentDocuments.bind('add', this.render);
     this.model.currentDocuments.bind('reset', this.render);
     this.model.currentDocuments.bind('remove', this.render);
-    this.state = {};
-    this.hiddenFields = [];
+    this.tempState = {};
+    var state = _.extend({
+        hiddenFields: []
+      }, modelEtc.state
+    ); 
+    this.state = new recline.Model.ObjectState(state);
   },
 
   events: {
-    'click .column-header-menu': 'onColumnHeaderClick',
+    'click .column-header-menu .data-table-menu li a': 'onColumnHeaderClick',
     'click .row-header-menu': 'onRowHeaderClick',
     'click .root-header-menu': 'onRootHeaderClick',
     'click .data-table-menu li a': 'onMenuClick'
@@ -1012,11 +1155,11 @@ my.DataGrid = Backbone.View.extend({
   // Column and row menus
 
   onColumnHeaderClick: function(e) {
-    this.state.currentColumn = $(e.target).closest('.column-header').attr('data-field');
+    this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
   },
 
   onRowHeaderClick: function(e) {
-    this.state.currentRow = $(e.target).parents('tr:first').attr('data-id');
+    this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id');
   },
   
   onRootHeaderClick: function(e) {
@@ -1024,7 +1167,7 @@ my.DataGrid = Backbone.View.extend({
         {{#columns}} \
         <li><a data-action="showColumn" data-column="{{.}}" href="JavaScript:void(0);">Show column: {{.}}</a></li> \
         {{/columns}}';
-    var tmp = $.mustache(tmpl, {'columns': this.hiddenFields});
+    var tmp = $.mustache(tmpl, {'columns': this.state.get('hiddenFields')});
     this.el.find('.root-header-menu .dropdown-menu').html(tmp);
   },
 
@@ -1032,15 +1175,15 @@ my.DataGrid = Backbone.View.extend({
     var self = this;
     e.preventDefault();
     var actions = {
-      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}); },
+      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); },
       facet: function() { 
-        self.model.queryState.addFacet(self.state.currentColumn);
+        self.model.queryState.addFacet(self.tempState.currentColumn);
       },
       facet_histogram: function() {
-        self.model.queryState.addHistogramFacet(self.state.currentColumn);
+        self.model.queryState.addHistogramFacet(self.tempState.currentColumn);
       },
       filter: function() {
-        self.model.queryState.addTermFilter(self.state.currentColumn, '');
+        self.model.queryState.addTermFilter(self.tempState.currentColumn, '');
       },
       transform: function() { self.showTransformDialog('transform'); },
       sortAsc: function() { self.setColumnSort('asc'); },
@@ -1051,7 +1194,7 @@ my.DataGrid = Backbone.View.extend({
         var doc = _.find(self.model.currentDocuments.models, function(doc) {
           // important this is == as the currentRow will be string (as comes
           // from DOM) while id may be int
-          return doc.id == self.state.currentRow;
+          return doc.id == self.tempState.currentRow;
         });
         doc.destroy().then(function() { 
             self.model.currentDocuments.remove(doc);
@@ -1070,7 +1213,7 @@ my.DataGrid = Backbone.View.extend({
     var view = new my.ColumnTransform({
       model: this.model
     });
-    view.state = this.state;
+    view.state = this.tempState;
     view.render();
     $el.empty();
     $el.append(view.el);
@@ -1096,17 +1239,22 @@ my.DataGrid = Backbone.View.extend({
 
   setColumnSort: function(order) {
     var sort = [{}];
-    sort[0][this.state.currentColumn] = {order: order};
+    sort[0][this.tempState.currentColumn] = {order: order};
     this.model.query({sort: sort});
   },
   
   hideColumn: function() {
-    this.hiddenFields.push(this.state.currentColumn);
+    var hiddenFields = this.state.get('hiddenFields');
+    hiddenFields.push(this.tempState.currentColumn);
+    this.state.set({hiddenFields: hiddenFields});
+    // change event not being triggered (because it is an array?) so trigger manually
+    this.state.trigger('change');
     this.render();
   },
   
   showColumn: function(e) {
-    this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column'));
+    var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column'));
+    this.state.set({hiddenFields: hiddenFields});
     this.render();
   },
 
@@ -1162,41 +1310,41 @@ my.DataGrid = Backbone.View.extend({
   render: function() {
     var self = this;
     this.fields = this.model.fields.filter(function(field) {
-      return _.indexOf(self.hiddenFields, field.id) == -1;
+      return _.indexOf(self.state.get('hiddenFields'), field.id) == -1;
     });
     var htmls = $.mustache(this.template, this.toTemplateJSON());
     this.el.html(htmls);
     this.model.currentDocuments.forEach(function(doc) {
       var tr = $('<tr />');
       self.el.find('tbody').append(tr);
-      var newView = new my.DataGridRow({
+      var newView = new my.GridRow({
           model: doc,
           el: tr,
           fields: self.fields
         });
       newView.render();
     });
-    this.el.toggleClass('no-hidden', (self.hiddenFields.length === 0));
+    this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
     return this;
   }
 });
 
-// ## DataGridRow View for rendering an individual document.
+// ## GridRow View for rendering an individual document.
 //
 // Since we want this to update in place it is up to creator to provider the element to attach to.
 //
-// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the DataGrid.
+// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the Grid.
 //
 // Example:
 //
 // <pre>
-// var row = new DataGridRow({
+// var row = new GridRow({
 //   model: dataset-document,
 //     el: dom-element,
 //     fields: mydatasets.fields // a FieldList object
 //   });
 // </pre>
-my.DataGridRow = Backbone.View.extend({
+my.GridRow = Backbone.View.extend({
   initialize: function(initData) {
     _.bindAll(this, 'render');
     this._fields = initData.fields;
@@ -1301,21 +1449,21 @@ this.recline.View = this.recline.View || {};
 // [GeoJSON](http://geojson.org) objects or two fields with latitude and
 // longitude coordinates.
 //
-// Initialization arguments:
-//
-// * options: initial options. They must contain a model:
-//
-//      {
-//          model: {recline.Model.Dataset}
-//      }
-//
-// * config: (optional) map configuration hash (not yet used)
-//
+// Initialization arguments are as standard for Dataset Views. State object may
+// have the following (optional) configuration options:
 //
+// <pre>
+//   {
+//     // geomField if specified will be used in preference to lat/lon
+//     geomField: {id of field containing geometry in the dataset}
+//     lonField: {id of field containing longitude in the dataset}
+//     latField: {id of field containing latitude in the dataset}
+//   }
+// </pre>
 my.Map = Backbone.View.extend({
 
   tagName:  'div',
-  className: 'data-map-container',
+  className: 'recline-map',
 
   template: ' \
   <div class="editor"> \
@@ -1364,6 +1512,11 @@ my.Map = Backbone.View.extend({
       <div class="editor-buttons"> \
         <button class="btn editor-update-map">Update</button> \
       </div> \
+      <div class="editor-options" > \
+        <label class="checkbox"> \
+          <input type="checkbox" id="editor-auto-zoom" checked="checked" /> \
+          Auto zoom to features</label> \
+      </div> \
       <input type="hidden" class="editor-id" value="map-1" /> \
       </div> \
     </form> \
@@ -1381,17 +1534,16 @@ my.Map = Backbone.View.extend({
   // Define here events for UI elements
   events: {
     'click .editor-update-map': 'onEditorSubmit',
-    'change .editor-field-type': 'onFieldTypeChange'
+    'change .editor-field-type': 'onFieldTypeChange',
+    'change #editor-auto-zoom': 'onAutoZoomChange'
   },
 
-
-  initialize: function(options, config) {
+  initialize: function(options) {
     var self = this;
-
     this.el = $(this.el);
 
     // Listen to changes in the fields
-    this.model.bind('change', function() {
+    this.model.fields.bind('change', function() {
       self._setupGeometryField();
     });
     this.model.fields.bind('add', this.render);
@@ -1402,17 +1554,40 @@ my.Map = Backbone.View.extend({
 
     // Listen to changes in the documents
     this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)});
+    this.model.currentDocuments.bind('change', function(doc){
+        self.redraw('remove',doc);
+        self.redraw('add',doc);
+    });
     this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)});
     this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});
 
-    // If the div was hidden, Leaflet needs to recalculate some sizes
-    // to display properly
     this.bind('view:show',function(){
+      // If the div was hidden, Leaflet needs to recalculate some sizes
+      // to display properly
+      if (self.map){
         self.map.invalidateSize();
+        if (self._zoomPending && self.autoZoom) {
+          self._zoomToFeatures();
+          self._zoomPending = false;
+        }
+      }
+      self.visible = true;
+    });
+    this.bind('view:hide',function(){
+      self.visible = false;
     });
 
-    this.mapReady = false;
+    var stateData = _.extend({
+        geomField: null,
+        lonField: null,
+        latField: null
+      },
+      options.state
+    );
+    this.state = new recline.Model.ObjectState(stateData);
 
+    this.autoZoom = true;
+    this.mapReady = false;
     this.render();
   },
 
@@ -1429,12 +1604,12 @@ my.Map = Backbone.View.extend({
     this.$map = this.el.find('.panel.map');
 
     if (this.geomReady && this.model.fields.length){
-      if (this._geomFieldName){
-        this._selectOption('editor-geom-field',this._geomFieldName);
+      if (this.state.get('geomField')){
+        this._selectOption('editor-geom-field',this.state.get('geomField'));
         $('#editor-field-type-geom').attr('checked','checked').change();
       } else{
-        this._selectOption('editor-lon-field',this._lonFieldName);
-        this._selectOption('editor-lat-field',this._latFieldName);
+        this._selectOption('editor-lon-field',this.state.get('lonField'));
+        this._selectOption('editor-lat-field',this.state.get('latField'));
         $('#editor-field-type-latlon').attr('checked','checked').change();
       }
     }
@@ -1463,9 +1638,7 @@ my.Map = Backbone.View.extend({
   // * refresh: Clear existing features and add all current documents
   //
   redraw: function(action,doc){
-
     var self = this;
-
     action = action || 'refresh';
 
     if (this.geomReady && this.mapReady){
@@ -1479,6 +1652,13 @@ my.Map = Backbone.View.extend({
         this.features.clearLayers();
         this._add(this.model.currentDocuments.models);
       }
+      if (action != 'reset' && this.autoZoom){
+        if (this.visible){
+          this._zoomToFeatures();
+        } else {
+          this._zoomPending = true;
+        }
+      }
     }
   },
 
@@ -1494,14 +1674,19 @@ my.Map = Backbone.View.extend({
   onEditorSubmit: function(e){
     e.preventDefault();
     if ($('#editor-field-type-geom').attr('checked')){
-        this._geomFieldName = $('.editor-geom-field > select > option:selected').val();
-        this._latFieldName = this._lonFieldName = false;
+      this.state.set({
+        geomField: $('.editor-geom-field > select > option:selected').val(),
+        lonField: null,
+        latField: null
+      });
     } else {
-        this._geomFieldName = false;
-        this._latFieldName = $('.editor-lat-field > select > option:selected').val();
-        this._lonFieldName = $('.editor-lon-field > select > option:selected').val();
+      this.state.set({
+        geomField: null,
+        lonField: $('.editor-lon-field > select > option:selected').val(),
+        latField: $('.editor-lat-field > select > option:selected').val()
+      });
     }
-    this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
+    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
     this.redraw();
 
     return false;
@@ -1520,6 +1705,10 @@ my.Map = Backbone.View.extend({
     }
   },
 
+  onAutoZoomChange: function(e){
+    this.autoZoom = !this.autoZoom;
+  },
+
   // Private: Add one or n features to the map
   //
   // For each document passed, a GeoJSON geometry will be extracted and added
@@ -1534,9 +1723,12 @@ my.Map = Backbone.View.extend({
 
     if (!(docs instanceof Array)) docs = [docs];
 
+    var count = 0;
+    var wrongSoFar = 0;
     _.every(docs,function(doc){
+      count += 1;
       var feature = self._getGeometryFromDocument(doc);
-      if (typeof feature === 'undefined'){
+      if (typeof feature === 'undefined' || feature === null){
         // Empty field
         return true;
       } else if (feature instanceof Object){
@@ -1544,7 +1736,9 @@ my.Map = Backbone.View.extend({
         // TODO: mustache?
         html = ''
         for (key in doc.attributes){
-          html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>'
+          if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
+            html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
+          }
         }
         feature.properties = {popupContent: html};
 
@@ -1553,16 +1747,20 @@ my.Map = Backbone.View.extend({
         feature.properties.cid = doc.cid;
 
         try {
-            self.features.addGeoJSON(feature);
+          self.features.addGeoJSON(feature);
         } catch (except) {
-            var msg = 'Wrong geometry value';
-            if (except.message) msg += ' (' + except.message + ')';
+          wrongSoFar += 1;
+          var msg = 'Wrong geometry value';
+          if (except.message) msg += ' (' + except.message + ')';
+          if (wrongSoFar <= 10) {
             my.notify(msg,{category:'error'});
-            return false;
+          }
         }
       } else {
-        my.notify('Wrong geometry value',{category:'error'});
-        return false;
+        wrongSoFar += 1
+        if (wrongSoFar <= 10) {
+          my.notify('Wrong geometry value',{category:'error'});
+        }
       }
       return true;
     });
@@ -1576,7 +1774,7 @@ my.Map = Backbone.View.extend({
 
     if (!(docs instanceof Array)) docs = [docs];
 
-    _.each(doc,function(doc){
+    _.each(docs,function(doc){
       for (key in self.features._layers){
         if (self.features._layers[key].cid == doc.cid){
           self.features.removeLayer(self.features._layers[key]);
@@ -1590,18 +1788,25 @@ my.Map = Backbone.View.extend({
   //
   _getGeometryFromDocument: function(doc){
     if (this.geomReady){
-      if (this._geomFieldName){
-        // We assume that the contents of the field are a valid GeoJSON object
-        return doc.attributes[this._geomFieldName];
-      } else if (this._lonFieldName && this._latFieldName){
+      if (this.state.get('geomField')){
+        var value = doc.get(this.state.get('geomField'));
+        if (typeof(value) === 'string'){
+          // We have a GeoJSON string representation
+          return $.parseJSON(value);
+        } else {
+          // We assume that the contents of the field are a valid GeoJSON object
+          return value;
+        }
+      } else if (this.state.get('lonField') && this.state.get('latField')){
         // We'll create a GeoJSON like point object from the two lat/lon fields
-        return {
-          type: 'Point',
-          coordinates: [
-            doc.attributes[this._lonFieldName],
-            doc.attributes[this._latFieldName]
-            ]
-        };
+        var lon = doc.get(this.state.get('lonField'));
+        var lat = doc.get(this.state.get('latField'));
+        if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
+          return {
+            type: 'Point',
+            coordinates: [lon,lat]
+          };
+        }
       }
       return null;
     }
@@ -1613,12 +1818,16 @@ my.Map = Backbone.View.extend({
   // If not found, the user can define them via the UI form.
   _setupGeometryField: function(){
     var geomField, latField, lonField;
-
-    this._geomFieldName = this._checkField(this.geometryFieldNames);
-    this._latFieldName = this._checkField(this.latitudeFieldNames);
-    this._lonFieldName = this._checkField(this.longitudeFieldNames);
-
-    this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
+    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
+    // should not overwrite if we have already set this (e.g. explicitly via state)
+    if (!this.geomReady) {
+      this.state.set({
+        geomField: this._checkField(this.geometryFieldNames),
+        latField: this._checkField(this.latitudeFieldNames),
+        lonField: this._checkField(this.longitudeFieldNames)
+      });
+      this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
+    }
   },
 
   // Private: Check if a field in the current model exists in the provided
@@ -1637,6 +1846,18 @@ my.Map = Backbone.View.extend({
     return null;
   },
 
+  // Private: Zoom to map to current features extent if any, or to the full
+  // extent if none.
+  //
+  _zoomToFeatures: function(){
+    var bounds = this.features.getBounds();
+    if (bounds){
+      this.map.fitBounds(bounds);
+    } else {
+      this.map.setView(new L.LatLng(0, 0), 2);
+    }
+  },
+
   // Private: Sets up the Leaflet map control and the features layer.
   //
   // The map uses a base layer from [MapQuest](http://www.mapquest.com) based
@@ -1661,6 +1882,24 @@ my.Map = Backbone.View.extend({
        }
 
     });
+
+    // This will be available in the next Leaflet stable release.
+    // In the meantime we add it manually to our layer.
+    this.features.getBounds = function(){
+      var bounds = new L.LatLngBounds();
+      this._iterateLayers(function (layer) {
+        if (layer instanceof L.Marker){
+          bounds.extend(layer.getLatLng());
+        } else {
+          if (layer.getBounds){
+            bounds.extend(layer.getBounds().getNorthEast());
+            bounds.extend(layer.getBounds().getSouthWest());
+          }
+        }
+      }, this);
+      return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
+    }
+
     this.map.addLayer(this.features);
 
     this.map.setView(new L.LatLng(0, 0), 2);
@@ -1895,6 +2134,85 @@ my.ColumnTransform = Backbone.View.extend({
 
 })(jQuery, recline.View);
 /*jshint multistr:true */
+
+// # Recline Views
+//
+// Recline Views are Backbone Views and in keeping with normal Backbone views
+// are Widgets / Components displaying something in the DOM. Like all Backbone
+// views they have a pointer to a model or a collection and is bound to an
+// element.
+//
+// Views provided by core Recline are crudely divided into two types:
+//
+// * Dataset Views: a View intended for displaying a recline.Model.Dataset
+//   in some fashion. Examples are the Grid, Graph and Map views.
+// * Widget Views: a widget used for displaying some specific (and
+//   smaller) aspect of a dataset or the application. Examples are
+//   QueryEditor and FilterEditor which both provide a way for editing (a
+//   part of) a `recline.Model.Query` associated to a Dataset.
+//
+// ## Dataset View
+//
+// These views are just Backbone views with a few additional conventions:
+//
+// 1. The model passed to the View should always be a recline.Model.Dataset instance
+// 2. Views should generate their own root element rather than having it passed
+//    in.
+// 3. Views should apply a css class named 'recline-{view-name-lower-cased} to
+//    the root element (and for all CSS for this view to be qualified using this
+//    CSS class)
+// 4. Read-only mode: CSS for this view should respect/utilize
+//    recline-read-only class to trigger read-only behaviour (this class will
+//    usually be set on some parent element of the view's root element.
+// 5. State: state (configuration) information for the view should be stored on
+//    an attribute named state that is an instance of a Backbone Model (or, more
+//    speficially, be an instance of `recline.Model.ObjectState`). In addition,
+//    a state attribute may be specified in the Hash passed to a View on
+//    iniitialization and this information should be used to set the initial
+//    state of the view.
+//
+//    Example of state would be the set of fields being plotted in a graph
+//    view.
+//
+//    More information about State can be found below.
+//
+// To summarize some of this, the initialize function for a Dataset View should
+// look like:
+//
+// <pre>
+//    initialize: {
+//        model: {a recline.Model.Dataset instance}
+//        // el: {do not specify - instead view should create}
+//        state: {(optional) Object / Hash specifying initial state}
+//        ...
+//    }
+// </pre>
+//
+// Note: Dataset Views in core Recline have a common layout on disk as
+// follows, where ViewName is the named of View class:
+//
+// <pre>
+// src/view-{lower-case-ViewName}.js
+// css/{lower-case-ViewName}.css
+// test/view-{lower-case-ViewName}.js
+// </pre>
+//
+// ### State
+//
+// State information exists in order to support state serialization into the
+// url or elsewhere and reloading of application from a stored state.
+//
+// State is available not only for individual views (as described above) but
+// for the dataset (e.g. the current query). For an example of pulling together
+// state from across multiple components see `recline.View.DataExplorer`.
+// 
+// ### Writing your own Views
+//
+// See the existing Views.
+//
+// ----
+
+// Standard JS module setup
 this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
 
@@ -1907,47 +2225,62 @@ this.recline.View = this.recline.View || {};
 // var myExplorer = new model.recline.DataExplorer({
 //   model: {{recline.Model.Dataset instance}}
 //   el: {{an existing dom element}}
-//   views: {{page views}}
-//   config: {{config options -- see below}}
+//   views: {{dataset views}}
+//   state: {{state configuration -- see below}}
 // });
 // </pre> 
 //
 // ### Parameters
 // 
-// **model**: (required) Dataset instance.
+// **model**: (required) recline.model.Dataset instance.
 //
-// **el**: (required) DOM element.
+// **el**: (required) DOM element to bind to. NB: the element already
+// being in the DOM is important for rendering of some subviews (e.g.
+// Graph).
 //
-// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to
-// show. This is an array of view hashes. If not provided
-// just initialize a DataGrid with id 'grid'. Example:
+// **views**: (optional) the dataset views (Grid, Graph etc) for
+// DataExplorer to show. This is an array of view hashes. If not provided
+// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id
+// and labels!).
 //
 // <pre>
 // var views = [
 //   {
 //     id: 'grid', // used for routing
 //     label: 'Grid', // used for view switcher
-//     view: new recline.View.DataGrid({
+//     view: new recline.View.Grid({
 //       model: dataset
 //     })
 //   },
 //   {
 //     id: 'graph',
 //     label: 'Graph',
-//     view: new recline.View.FlotGraph({
+//     view: new recline.View.Graph({
 //       model: dataset
 //     })
 //   }
 // ];
 // </pre>
 //
-// **config**: Config options like:
+// **state**: standard state config for this view. This state is slightly
+//  special as it includes config of many of the subviews.
 //
-//   * readOnly: true/false (default: false) value indicating whether to
-//     operate in read-only mode (hiding all editing options).
+// <pre>
+// state = {
+//     query: {dataset query state - see dataset.queryState object}
+//     view-{id1}: {view-state for this view}
+//     view-{id2}: {view-state for }
+//     ...
+//     // Explorer
+//     currentView: id of current view (defaults to first view if not specified)
+//     readOnly: (default: false) run in read-only mode
+// }
+// </pre>
 //
-// NB: the element already being in the DOM is important for rendering of
-// FlotGraph subview.
+// Note that at present we do *not* serialize information about the actual set
+// of views in use -- e.g. those specified by the views argument -- but instead 
+// expect either that the default views are fine or that the client to have
+// initialized the DataExplorer with the relevant views themselves.
 my.DataExplorer = Backbone.View.extend({
   template: ' \
   <div class="recline-data-explorer"> \
@@ -1956,7 +2289,7 @@ my.DataExplorer = Backbone.View.extend({
     <div class="header"> \
       <ul class="navigation"> \
         {{#views}} \
-        <li><a href="#{{id}}" class="btn">{{label}}</a> \
+        <li><a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
         {{/views}} \
       </ul> \
       <div class="recline-results-info"> \
@@ -1979,19 +2312,14 @@ my.DataExplorer = Backbone.View.extend({
   </div> \
   ',
   events: {
-    'click .menu-right a': 'onMenuClick'
+    'click .menu-right a': '_onMenuClick',
+    'click .navigation a': '_onSwitchView'
   },
 
   initialize: function(options) {
     var self = this;
     this.el = $(this.el);
-    this.config = _.extend({
-        readOnly: false
-      },
-      options.config);
-    if (this.config.readOnly) {
-      this.setReadOnly();
-    }
+    this._setupState(options.state);
     // Hash of 'page' views (i.e. those for whole page) keyed by page name
     if (options.views) {
       this.pageViews = options.views;
@@ -1999,13 +2327,38 @@ my.DataExplorer = Backbone.View.extend({
       this.pageViews = [{
         id: 'grid',
         label: 'Grid',
-        view: new my.DataGrid({
-            model: this.model
-          })
+        view: new my.Grid({
+          model: this.model,
+          state: this.state.get('view-grid')
+        }),
+      }, {
+        id: 'graph',
+        label: 'Graph',
+        view: new my.Graph({
+          model: this.model,
+          state: this.state.get('view-graph')
+        }),
+      }, {
+        id: 'map',
+        label: 'Map',
+        view: new my.Map({
+          model: this.model,
+          state: this.state.get('view-map')
+        }),
       }];
     }
-    // this must be called after pageViews are created
+    // these must be called after pageViews are created
     this.render();
+    this._bindStateChanges();
+    // now do updates based on state (need to come after render)
+    if (this.state.get('readOnly')) {
+      this.setReadOnly();
+    }
+    if (this.state.get('currentView')) {
+      this.updateNav(this.state.get('currentView'));
+    } else {
+      this.updateNav(this.pageViews[0].id);
+    }
 
     this.router = new Backbone.Router();
     this.setupRouting();
@@ -2021,7 +2374,7 @@ my.DataExplorer = Backbone.View.extend({
         var qs = my.parseHashQueryString();
         qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON());
         var out = my.getNewHashForQueryString(qs);
-        self.router.navigate(out);
+        // self.router.navigate(out);
       });
     this.model.bind('query:fail', function(error) {
         my.clearNotifications();
@@ -2045,11 +2398,7 @@ my.DataExplorer = Backbone.View.extend({
     // note this.model and dataset returned are the same
     this.model.fetch()
       .done(function(dataset) {
-        var queryState = my.parseHashQueryString().reclineQuery;
-        if (queryState) {
-          queryState = JSON.parse(queryState);
-        }
-        self.model.query(queryState);
+        self.model.query(self.state.get('query'));
       })
       .fail(function(error) {
         my.notify(error.message, {category: 'error', persist: true});
@@ -2057,12 +2406,11 @@ my.DataExplorer = Backbone.View.extend({
   },
 
   setReadOnly: function() {
-    this.el.addClass('read-only');
+    this.el.addClass('recline-read-only');
   },
 
   render: function() {
     var tmplData = this.model.toTemplateJSON();
-    tmplData.displayCount = this.config.displayCount;
     tmplData.views = this.pageViews;
     var template = $.mustache(this.template, tmplData);
     $(this.el).html(template);
@@ -2089,20 +2437,22 @@ my.DataExplorer = Backbone.View.extend({
   setupRouting: function() {
     var self = this;
     // Default route
-    this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
-      self.updateNav(self.pageViews[0].id, queryString);
-    });
-    $.each(this.pageViews, function(idx, view) {
-      self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
-        self.updateNav(viewId, queryString);
-      });
+//    this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
+//      self.updateNav(self.pageViews[0].id, queryString);
+//    });
+//    $.each(this.pageViews, function(idx, view) {
+//      self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
+//        self.updateNav(viewId, queryString);
+//      });
+//    });
+    this.router.route(/.*/, 'view', function() {
     });
   },
 
-  updateNav: function(pageName, queryString) {
+  updateNav: function(pageName) {
     this.el.find('.navigation li').removeClass('active');
     this.el.find('.navigation li a').removeClass('disabled');
-    var $el = this.el.find('.navigation li a[href=#' + pageName + ']');
+    var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]');
     $el.parent().addClass('active');
     $el.addClass('disabled');
     // show the specific page
@@ -2117,7 +2467,7 @@ my.DataExplorer = Backbone.View.extend({
     });
   },
 
-  onMenuClick: function(e) {
+  _onMenuClick: function(e) {
     e.preventDefault();
     var action = $(e.target).attr('data-action');
     if (action === 'filters') {
@@ -2125,9 +2475,78 @@ my.DataExplorer = Backbone.View.extend({
     } else if (action === 'facets') {
       this.$facetViewer.show();
     }
+  },
+
+  _onSwitchView: function(e) {
+    e.preventDefault();
+    var viewName = $(e.target).attr('data-view');
+    this.updateNav(viewName);
+    this.state.set({currentView: viewName});
+  },
+
+  // create a state object for this view and do the job of
+  // 
+  // a) initializing it from both data passed in and other sources (e.g. hash url)
+  //
+  // b) ensure the state object is updated in responese to changes in subviews, query etc.
+  _setupState: function(initialState) {
+    var self = this;
+    // get data from the query string / hash url plus some defaults
+    var qs = my.parseHashQueryString();
+    var query = qs.reclineQuery;
+    query = query ? JSON.parse(query) : self.model.queryState.toJSON();
+    // backwards compatability (now named view-graph but was named graph)
+    var graphState = qs['view-graph'] || qs.graph;
+    graphState = graphState ? JSON.parse(graphState) : {};
+
+    // now get default data + hash url plus initial state and initial our state object with it
+    var stateData = _.extend({
+        query: query,
+        'view-graph': graphState,
+        backend: this.model.backend.__type__,
+        dataset: this.model.toJSON(),
+        currentView: null,
+        readOnly: false
+      },
+      initialState);
+    this.state = new recline.Model.ObjectState(stateData);
+  },
+
+  _bindStateChanges: function() {
+    var self = this;
+    // finally ensure we update our state object when state of sub-object changes so that state is always up to date
+    this.model.queryState.bind('change', function() {
+      self.state.set({query: self.model.queryState.toJSON()});
+    });
+    _.each(this.pageViews, function(pageView) {
+      if (pageView.view.state && pageView.view.state.bind) {
+        var update = {};
+        update['view-' + pageView.id] = pageView.view.state.toJSON();
+        self.state.set(update);
+        pageView.view.state.bind('change', function() {
+          var update = {};
+          update['view-' + pageView.id] = pageView.view.state.toJSON();
+          // had problems where change not being triggered for e.g. grid view so let's do it explicitly
+          self.state.set(update, {silent: true});
+          self.state.trigger('change');
+        });
+      }
+    });
   }
 });
 
+// ### DataExplorer.restore
+//
+// Restore a DataExplorer instance from a serialized state including the associated dataset
+my.DataExplorer.restore = function(state) {
+  var dataset = recline.Model.Dataset.restore(state);
+  var explorer = new my.DataExplorer({
+    model: dataset,
+    state: state
+  });
+  return explorer;
+}
+
 my.QueryEditor = Backbone.View.extend({
   className: 'recline-query-editor', 
   template: ' \
@@ -2403,6 +2822,9 @@ my.composeQueryString = function(queryParams) {
   var queryString = '?';
   var items = [];
   $.each(queryParams, function(key, value) {
+    if (typeof(value) === 'object') {
+      value = JSON.stringify(value);
+    }
     items.push(key + '=' + value);
   });
   queryString += items.join('&');
@@ -2484,10 +2906,27 @@ this.recline.Backend = this.recline.Backend || {};
   // ## recline.Backend.Base
   //
   // Base class for backends providing a template and convenience functions.
-  // You do not have to inherit from this class but even when not it does provide guidance on the functions you must implement.
+  // You do not have to inherit from this class but even when not it does
+  // provide guidance on the functions you must implement.
   //
   // Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.
   my.Base = Backbone.Model.extend({
+    // ### __type__
+    //
+    // 'type' of this backend. This should be either the class path for this
+    // object as a string (e.g. recline.Backend.Memory) or for Backends within
+    // recline.Backend module it may be their class name.
+    //
+    // This value is used as an identifier for this backend when initializing
+    // backends (see recline.Model.Dataset.initialize).
+    __type__: 'base',
+
+
+    // ### readonly
+    //
+    // Class level attribute indicating that this backend is read-only (that
+    // is, cannot be written to).
+    readonly: true,
 
     // ### sync
     //
@@ -2549,6 +2988,32 @@ this.recline.Backend = this.recline.Backend || {};
     query: function(model, queryObj) {
     },
 
+    // ### _makeRequest
+    // 
+    // Just $.ajax but in any headers in the 'headers' attribute of this
+    // Backend instance. Example:
+    //
+    // <pre>
+    // var jqxhr = this._makeRequest({
+    //   url: the-url
+    // });
+    // </pre>
+    _makeRequest: function(data) {
+      var headers = this.get('headers');
+      var extras = {};
+      if (headers) {
+        extras = {
+          beforeSend: function(req) {
+            _.each(headers, function(value, key) {
+              req.setRequestHeader(key, value);
+            });
+          }
+        };
+      }
+      var data = _.extend(extras, data);
+      return $.ajax(data);
+    },
+
     // convenience method to convert simple set of documents / rows to a QueryResult
     _docsToQueryResult: function(rows) {
       var hits = _.map(rows, function(row) {
@@ -2607,6 +3072,8 @@ this.recline.Backend = this.recline.Backend || {};
   //
   // Note that this is a **read-only** backend.
   my.DataProxy = my.Base.extend({
+    __type__: 'dataproxy',
+    readonly: true,
     defaults: {
       dataproxy_url: 'http://jsonpdataproxy.appspot.com'
     },
@@ -2661,8 +3128,6 @@ this.recline.Backend = this.recline.Backend || {};
       return dfd.promise();
     }
   });
-  recline.Model.backends['dataproxy'] = new my.DataProxy();
-
 
 }(jQuery, this.recline.Backend));
 this.recline = this.recline || {};
@@ -2673,35 +3138,39 @@ this.recline.Backend = this.recline.Backend || {};
   //
   // Connecting to [ElasticSearch](http://www.elasticsearch.org/).
   //
-  // To use this backend ensure your Dataset has one of the following
-  // attributes (first one found is used):
+  // Usage:
+  //
+  // <pre>
+  // var backend = new recline.Backend.ElasticSearch({
+  //   // optional as can also be provided by Dataset/Document
+  //   url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
+  //   // optional
+  //   headers: {dict of headers to add to each request}
+  // });
+  //
+  // @param {String} url: url for ElasticSearch type/table, e.g. for ES running
+  // on localhost:9200 with index // twitter and type tweet it would be:
+  // 
+  // <pre>http://localhost:9200/twitter/tweet</pre>
+  //
+  // This url is optional since the ES endpoint url may be specified on the the
+  // dataset (and on a Document by the document having a dataset attribute) by
+  // having one of the following (see also `_getESUrl` function):
   //
   // <pre>
   // elasticsearch_url
   // webstore_url
   // url
   // </pre>
-  //
-  // This should point to the ES type url. E.G. for ES running on
-  // localhost:9200 with index twitter and type tweet it would be
-  //
-  // <pre>http://localhost:9200/twitter/tweet</pre>
   my.ElasticSearch = my.Base.extend({
-    _getESUrl: function(dataset) {
-      var out = dataset.get('elasticsearch_url');
-      if (out) return out;
-      out = dataset.get('webstore_url');
-      if (out) return out;
-      out = dataset.get('url');
-      return out;
-    },
+    __type__: 'elasticsearch',
+    readonly: false,
     sync: function(method, model, options) {
       var self = this;
       if (method === "read") {
         if (model.__type__ == 'Dataset') {
-          var base = self._getESUrl(model);
-          var schemaUrl = base + '/_mapping';
-          var jqxhr = $.ajax({
+          var schemaUrl = self._getESUrl(model) + '/_mapping';
+          var jqxhr = this._makeRequest({
             url: schemaUrl,
             dataType: 'jsonp'
           });
@@ -2720,11 +3189,77 @@ this.recline.Backend = this.recline.Backend || {};
             dfd.reject(arguments);
           });
           return dfd.promise();
+        } else if (model.__type__ == 'Document') {
+          var base = this._getESUrl(model.dataset) + '/' + model.id;
+          return this._makeRequest({
+            url: base,
+            dataType: 'json'
+          });
+        }
+      } else if (method === 'update') {
+        if (model.__type__ == 'Document') {
+          return this.upsert(model.toJSON(), this._getESUrl(model.dataset));
+        }
+      } else if (method === 'delete') {
+        if (model.__type__ == 'Document') {
+          var url = this._getESUrl(model.dataset);
+          return this.delete(model.id, url);
         }
-      } else {
-        alert('This backend currently only supports read operations');
       }
     },
+
+    // ### upsert
+    //
+    // create / update a document to ElasticSearch backend
+    //
+    // @param {Object} doc an object to insert to the index.
+    // @param {string} url (optional) url for ElasticSearch endpoint (if not
+    // defined called this._getESUrl()
+    upsert: function(doc, url) {
+      var data = JSON.stringify(doc);
+      url = url ? url : this._getESUrl();
+      if (doc.id) {
+        url += '/' + doc.id;
+      }
+      return this._makeRequest({
+        url: url,
+        type: 'POST',
+        data: data,
+        dataType: 'json'
+      });
+    },
+
+    // ### delete
+    //
+    // Delete a document from the ElasticSearch backend.
+    //
+    // @param {Object} id id of object to delete
+    // @param {string} url (optional) url for ElasticSearch endpoint (if not
+    // provided called this._getESUrl()
+    delete: function(id, url) {
+      url = url ? url : this._getESUrl();
+      url += '/' + id;
+      return this._makeRequest({
+        url: url,
+        type: 'DELETE',
+        dataType: 'json'
+      });
+    },
+
+    // ### _getESUrl
+    //
+    // get url to ElasticSearch endpoint (see above)
+    _getESUrl: function(dataset) {
+      if (dataset) {
+        var out = dataset.get('elasticsearch_url');
+        if (out) return out;
+        out = dataset.get('webstore_url');
+        if (out) return out;
+        out = dataset.get('url');
+        return out;
+      }
+      return this.get('url');
+    },
     _normalizeQuery: function(queryObj) {
       var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
       if (out.q !== undefined && out.q.trim() === '') {
@@ -2761,7 +3296,7 @@ this.recline.Backend = this.recline.Backend || {};
       var queryNormalized = this._normalizeQuery(queryObj);
       var data = {source: JSON.stringify(queryNormalized)};
       var base = this._getESUrl(model);
-      var jqxhr = $.ajax({
+      var jqxhr = this._makeRequest({
         url: base + '/_search',
         data: data,
         dataType: 'jsonp'
@@ -2782,7 +3317,6 @@ this.recline.Backend = this.recline.Backend || {};
       return dfd.promise();
     }
   });
-  recline.Model.backends['elasticsearch'] = new my.ElasticSearch();
 
 }(jQuery, this.recline.Backend));
 
@@ -2805,6 +3339,8 @@ this.recline.Backend = this.recline.Backend || {};
   // );
   // </pre>
   my.GDoc = my.Base.extend({
+    __type__: 'gdoc',
+    readonly: true,
     getUrl: function(dataset) {
       var url = dataset.get('url');
       if (url.indexOf('feeds/list') != -1) {
@@ -2922,7 +3458,6 @@ this.recline.Backend = this.recline.Backend || {};
       return results;
     }
   });
-  recline.Model.backends['gdocs'] = new my.GDoc();
 
 }(jQuery, this.recline.Backend));
 
@@ -2930,7 +3465,9 @@ this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 
 (function($, my) {
-  my.loadFromCSVFile = function(file, callback) {
+  my.loadFromCSVFile = function(file, callback, options) {
+    var encoding = options.encoding || 'UTF-8';
+    
     var metadata = {
       id: file.name,
       file: file
@@ -2938,17 +3475,17 @@ this.recline.Backend = this.recline.Backend || {};
     var reader = new FileReader();
     // TODO
     reader.onload = function(e) {
-      var dataset = my.csvToDataset(e.target.result);
+      var dataset = my.csvToDataset(e.target.result, options);
       callback(dataset);
     };
     reader.onerror = function (e) {
       alert('Failed to load file. Code: ' + e.target.error.code);
     };
-    reader.readAsText(file);
+    reader.readAsText(file, encoding);
   };
 
-  my.csvToDataset = function(csvString) {
-    var out = my.parseCSV(csvString);
+  my.csvToDataset = function(csvString, options) {
+    var out = my.parseCSV(csvString, options);
     fields = _.map(out[0], function(cell) {
       return { id: cell, label: cell };
     });
@@ -2963,128 +3500,135 @@ this.recline.Backend = this.recline.Backend || {};
     return dataset;
   };
 
-	// Converts a Comma Separated Values string into an array of arrays.
-	// Each line in the CSV becomes an array.
+  // Converts a Comma Separated Values string into an array of arrays.
+  // Each line in the CSV becomes an array.
   //
-	// Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
-  //
-	// @return The CSV parsed as an array
-	// @type Array
-	// 
-	// @param {String} s The string to convert
-	// @param {Boolean} [trm=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
+  // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
   //
+  // @return The CSV parsed as an array
+  // @type Array
+  // 
+  // @param {String} s The string to convert
+  // @param {Object} options Options for loading CSV including
+  // 	@param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
+  //	@param {String} [separator=','] Separator for CSV file
   // Heavily based on uselesscode's JS CSV parser (MIT Licensed):
   // thttp://www.uselesscode.org/javascript/csv/
-	my.parseCSV= function(s, trm) {
-		// Get rid of any trailing \n
-		s = chomp(s);
-
-		var cur = '', // The character we are currently processing.
-			inQuote = false,
-			fieldQuoted = false,
-			field = '', // Buffer for building up the current field
-			row = [],
-			out = [],
-			i,
-			processField;
-
-		processField = function (field) {
-			if (fieldQuoted !== true) {
-				// If field is empty set to null
-				if (field === '') {
-					field = null;
-				// If the field was not quoted and we are trimming fields, trim it
-				} else if (trm === true) {
-					field = trim(field);
-				}
-
-				// Convert unquoted numbers to their appropriate types
-				if (rxIsInt.test(field)) {
-					field = parseInt(field, 10);
-				} else if (rxIsFloat.test(field)) {
-					field = parseFloat(field, 10);
-				}
-			}
-			return field;
-		};
+  my.parseCSV= function(s, options) {
+    // Get rid of any trailing \n
+    s = chomp(s);
+
+    var options = options || {};
+    var trm = options.trim;
+    var separator = options.separator || ',';
+    var delimiter = options.delimiter || '"';
+
+
+    var cur = '', // The character we are currently processing.
+      inQuote = false,
+      fieldQuoted = false,
+      field = '', // Buffer for building up the current field
+      row = [],
+      out = [],
+      i,
+      processField;
+
+    processField = function (field) {
+      if (fieldQuoted !== true) {
+        // If field is empty set to null
+        if (field === '') {
+          field = null;
+        // If the field was not quoted and we are trimming fields, trim it
+        } else if (trm === true) {
+          field = trim(field);
+        }
 
-		for (i = 0; i < s.length; i += 1) {
-			cur = s.charAt(i);
-
-			// If we are at a EOF or EOR
-			if (inQuote === false && (cur === ',' || cur === "\n")) {
-				field = processField(field);
-				// Add the current field to the current row
-				row.push(field);
-				// If this is EOR append row to output and flush row
-				if (cur === "\n") {
-					out.push(row);
-					row = [];
-				}
-				// Flush the field buffer
-				field = '';
-				fieldQuoted = false;
-			} else {
-				// If it's not a ", add it to the field buffer
-				if (cur !== '"') {
-					field += cur;
-				} else {
-					if (!inQuote) {
-						// We are not in a quote, start a quote
-						inQuote = true;
-						fieldQuoted = true;
-					} else {
-						// Next char is ", this is an escaped "
-						if (s.charAt(i + 1) === '"') {
-							field += '"';
-							// Skip the next char
-							i += 1;
-						} else {
-							// It's not escaping, so end quote
-							inQuote = false;
-						}
-					}
-				}
-			}
-		}
-
-		// Add the last field
-		field = processField(field);
-		row.push(field);
-		out.push(row);
-
-		return out;
-	};
-
-	var rxIsInt = /^\d+$/,
-		rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
-		// If a string has leading or trailing space,
-		// contains a comma double quote or a newline
-		// it needs to be quoted in CSV output
-		rxNeedsQuoting = /^\s|\s$|,|"|\n/,
-		trim = (function () {
-			// Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
-			if (String.prototype.trim) {
-				return function (s) {
-					return s.trim();
-				};
-			} else {
-				return function (s) {
-					return s.replace(/^\s*/, '').replace(/\s*$/, '');
-				};
-			}
-		}());
-
-	function chomp(s) {
-		if (s.charAt(s.length - 1) !== "\n") {
-			// Does not end with \n, just return string
-			return s;
-		} else {
-			// Remove the \n
-			return s.substring(0, s.length - 1);
-		}
-	}
+        // Convert unquoted numbers to their appropriate types
+        if (rxIsInt.test(field)) {
+          field = parseInt(field, 10);
+        } else if (rxIsFloat.test(field)) {
+          field = parseFloat(field, 10);
+        }
+      }
+      return field;
+    };
+
+    for (i = 0; i < s.length; i += 1) {
+      cur = s.charAt(i);
+
+      // If we are at a EOF or EOR
+      if (inQuote === false && (cur === separator || cur === "\n")) {
+	field = processField(field);
+        // Add the current field to the current row
+        row.push(field);
+        // If this is EOR append row to output and flush row
+        if (cur === "\n") {
+          out.push(row);
+          row = [];
+        }
+        // Flush the field buffer
+        field = '';
+        fieldQuoted = false;
+      } else {
+        // If it's not a delimiter, add it to the field buffer
+        if (cur !== delimiter) {
+          field += cur;
+        } else {
+          if (!inQuote) {
+            // We are not in a quote, start a quote
+            inQuote = true;
+            fieldQuoted = true;
+          } else {
+            // Next char is delimiter, this is an escaped delimiter
+            if (s.charAt(i + 1) === delimiter) {
+              field += delimiter;
+              // Skip the next char
+              i += 1;
+            } else {
+              // It's not escaping, so end quote
+              inQuote = false;
+            }
+          }
+        }
+      }
+    }
+
+    // Add the last field
+    field = processField(field);
+    row.push(field);
+    out.push(row);
+
+    return out;
+  };
+
+  var rxIsInt = /^\d+$/,
+    rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
+    // If a string has leading or trailing space,
+    // contains a comma double quote or a newline
+    // it needs to be quoted in CSV output
+    rxNeedsQuoting = /^\s|\s$|,|"|\n/,
+    trim = (function () {
+      // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
+      if (String.prototype.trim) {
+        return function (s) {
+          return s.trim();
+        };
+      } else {
+        return function (s) {
+          return s.replace(/^\s*/, '').replace(/\s*$/, '');
+        };
+      }
+    }());
+
+  function chomp(s) {
+    if (s.charAt(s.length - 1) !== "\n") {
+      // Does not end with \n, just return string
+      return s;
+    } else {
+      // Remove the \n
+      return s.substring(0, s.length - 1);
+    }
+  }
 
 
 }(jQuery, this.recline.Backend));
@@ -3110,7 +3654,7 @@ this.recline.Backend = this.recline.Backend || {};
     if (!metadata.id) {
       metadata.id = String(Math.floor(Math.random() * 100000000) + 1);
     }
-    var backend = recline.Model.backends['memory'];
+    var backend = new recline.Backend.Memory();
     var datasetInfo = {
       documents: data,
       metadata: metadata
@@ -3125,7 +3669,7 @@ this.recline.Backend = this.recline.Backend || {};
       }
     }
     backend.addDataset(datasetInfo);
-    var dataset = new recline.Model.Dataset({id: metadata.id}, 'memory');
+    var dataset = new recline.Model.Dataset({id: metadata.id}, backend);
     dataset.fetch();
     return dataset;
   };
@@ -3160,6 +3704,8 @@ this.recline.Backend = this.recline.Backend || {};
   //  etc ...
   //  </pre>
   my.Memory = my.Base.extend({
+    __type__: 'memory',
+    readonly: false,
     initialize: function() {
       this.datasets = {};
     },
@@ -3207,13 +3753,9 @@ this.recline.Backend = this.recline.Backend || {};
       var out = {};
       var numRows = queryObj.size;
       var start = queryObj.from;
-      results = this.datasets[model.id].documents;
-      _.each(queryObj.filters, function(filter) {
-        results = _.filter(results, function(doc) {
-          var fieldId = _.keys(filter.term)[0];
-          return (doc[fieldId] == filter.term[fieldId]);
-        });
-      });
+      var results = this.datasets[model.id].documents;
+      results = this._applyFilters(results, queryObj);
+      results = this._applyFreeTextQuery(model, results, queryObj);
       // not complete sorting!
       _.each(queryObj.sort, function(sortObj) {
         var fieldName = _.keys(sortObj)[0];
@@ -3231,6 +3773,43 @@ this.recline.Backend = this.recline.Backend || {};
       return dfd.promise();
     },
 
+    // in place filtering
+    _applyFilters: function(results, queryObj) {
+      _.each(queryObj.filters, function(filter) {
+        results = _.filter(results, function(doc) {
+          var fieldId = _.keys(filter.term)[0];
+          return (doc[fieldId] == filter.term[fieldId]);
+        });
+      });
+      return results;
+    },
+
+    // we OR across fields but AND across terms in query string
+    _applyFreeTextQuery: function(dataset, results, queryObj) {
+      if (queryObj.q) {
+        var terms = queryObj.q.split(' ');
+        results = _.filter(results, function(rawdoc) {
+          var matches = true;
+          _.each(terms, function(term) {
+            var foundmatch = false;
+            dataset.fields.each(function(field) {
+              var value = rawdoc[field.id];
+              if (value !== null) { value = value.toString(); }
+              // TODO regexes?
+              foundmatch = foundmatch || (value === term);
+              // TODO: early out (once we are true should break to spare unnecessary testing)
+              // if (foundmatch) return true;
+            });
+            matches = matches && foundmatch;
+            // TODO: early out (once false should break to spare unnecessary testing)
+            // if (!matches) return false;
+          });
+          return matches;
+        });
+      }
+      return results;
+    },
+
     _computeFacets: function(documents, queryObj) {
       var facetResults = {};
       if (!queryObj.facets) {
@@ -3267,6 +3846,5 @@ this.recline.Backend = this.recline.Backend || {};
       return facetResults;
     }
   });
-  recline.Model.backends['memory'] = new my.Memory();
 
 }(jQuery, this.recline.Backend));
diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html
index 7886bdd..c474402 100644
--- a/ckan/templates/group/read.html
+++ b/ckan/templates/group/read.html
@@ -43,48 +43,12 @@ <h3 py:if="c.group['state'] != 'active'">State: ${c.group['state']}</h3>
     </div>
     <div class="group-dataset-list">
       <h3>Datasets</h3>
-
-      <form id="dataset-search" class="dataset-search" method="GET">
-        <input type="search" class="search" name="q" value="${c.q}" autocomplete="off" results="0" placeholder="${_('Search')}..." />
-        <py:for each="(k, v) in c.fields"> 
-          <input type="hidden" name="${k}" value="${v}" />  
-        </py:for>
-        <input type="submit" value="${_('Search')}" class="btn primary button" />
-      </form>
+  <xi:include href="../package/search_form.html" />
       ${field_list()}   
 
       <p i18n:msg="query, number_of_results"><span py:if="c.q">You searched for "${c.q}". </span>${c.page.item_count} datasets found.</p>
       ${c.page.pager()}
-
-      <py:for each="package in c.page.items">
-        <div class="search-result ${'fullyopen' if (package.isopen and package.get('resources')) else None}">
-          <p class="extra-links">
-            <a class="view-more-link" href="${h.url_for(controller='package', action='read', id=package.get('name'))}">View</a>
-          </p>
-          <a class="main-link" href="${h.url_for(controller='package', action='read', id=package.get('name'))}">${package.get('title') or package.get('name')}</a>
-            
-          <py:if test="package.resources">
-            <py:for each="resource in package.resources">
-              <py:if test="resource.get('format')">
-                <a href="${resource.get('url')}"
-                  title="${resource.get('description')}"><span class="format-box">${resource.get('format')}</span></a>
-              </py:if>
-            </py:for>
-          </py:if>
-          <p class="result-description">${h.markdown_extract(package.notes)}</p>
-
-          <span class="result-url">
-            <py:if test="package.isopen">
-              <a href="http://opendefinition.org/okd/" title="This dataset satisfies the Open Definition.">
-                <img src="http://assets.okfn.org/images/ok_buttons/od_80x15_blue.png" alt="[Open Data]" />
-              </a>
-            </py:if>
-            <py:if test="not package.isopen">
-              ${h.icon('lock')} Not Openly Licensed
-            </py:if>
-          </span>
-        </div>
-      </py:for>
+      ${package_list_from_dict(c.page.items)}
       ${c.page.pager()}
     </div>
   </py:match>
diff --git a/ckan/templates/layout_base.html b/ckan/templates/layout_base.html
index 1518d72..a983d4b 100644
--- a/ckan/templates/layout_base.html
+++ b/ckan/templates/layout_base.html
@@ -266,6 +266,28 @@ <h3 class="widget-title">Meta</h3>
     });
   </script>
 
+  <script type="text/javascript">
+         $(function (){
+                 // Tracking
+				 var url = location.pathname;
+				 // remove any site root from url
+				 url = url.substring(CKAN.SITE_URL.length, url.length - 1);
+				 $.ajax({url : CKAN.SITE_URL + '/_tracking',
+						 type : 'POST',
+						 data : {url:url, type:'page'},
+						 timeout : 300 });
+                 $('a.resource-url-analytics').click(function (e){
+                         var url = $(e.target).closest('a').attr('href');
+						 $.ajax({url : CKAN.SITE_URL + '/_tracking',
+							     data : {url:url, type:'resource'},
+								 type : 'POST',
+								 complete : function () {location.href = url;},
+								 timeout : 30});
+                         e.preventDefault();
+                         });
+                 });
+  </script>
+
   <py:if test="defined('optional_footer')">
     ${optional_footer()}
   </py:if>
diff --git a/ckan/templates/package/layout.html b/ckan/templates/package/layout.html
index ea07cbb..5015093 100644
--- a/ckan/templates/package/layout.html
+++ b/ckan/templates/package/layout.html
@@ -27,7 +27,7 @@
                 <hr py:if="len(c.pkg_dict.get('resources',[]))>0"/>
               </li>
               <li py:for="res in c.pkg_dict.get('resources', [])">
-                <a href="${h.url_for(controller='package', action='resource_read', id=c.pkg_dict['name'], resource_id=res['id'])}">${h.resource_icon(res) + h.resource_display_name(res)}</a>
+			  <a href="${h.url_for(controller='package', action='resource_read', id=c.pkg_dict['name'], resource_id=res['id'])}">${h.resource_icon(res) + h.resource_display_name(res)}</a>
               </li>
             </ul>
           </div>
diff --git a/ckan/templates/package/resource_embedded_dataviewer.html b/ckan/templates/package/resource_embedded_dataviewer.html
new file mode 100644
index 0000000..5022a4d
--- /dev/null
+++ b/ckan/templates/package/resource_embedded_dataviewer.html
@@ -0,0 +1,98 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+  xmlns:i18n="http://genshi.edgewall.org/i18n"
+  xmlns:py="http://genshi.edgewall.org/" 
+  xmlns:xi="http://www.w3.org/2001/XInclude"
+  py:strip="">
+
+  <py:def function="optional_head">
+    <!-- data preview -->
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.css')}" />
+    <!--[if lte IE 8]>
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.ie.css')}" />
+    <![endif]-->
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/data-explorer.css')}" />
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph.css')}" />
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/map.css')}" />
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/grid.css')}" />
+    <style type="text/css">
+
+      /* Hide the query controls */
+      .header {
+        display: none;
+      }
+
+      /* Hide CKAN footer */
+      .footer.outer {
+        display: none;
+      }
+
+      /* Don't center the main container. And provide a little space to the
+         left and above the viewer.  This is for the graph-view, which, if a
+         small amount of room is not given, the y-axis' labels are uncomfortably
+         close to the edge of the viewport.
+      */
+      #main.container {
+        width: auto;
+        margin-left: 2px;
+      }
+
+      /* Remove the border from the right-hand-side */
+      #content {
+        border: 0px;
+      }
+
+      #ckanext-datapreview {
+        width: ${c.width-2}px;
+        height: ${c.height-81}px;
+      }
+
+      .recline-grid-container {
+        height: ${c.height-81}px;
+      }
+
+      .recline-graph .graph {
+        height: ${c.height-81}px;
+      }
+
+      .recline-map .map {
+        height: ${c.height-81}px;
+      }
+
+      .branded-link {
+        height: 34px;
+      }
+
+      .alert-messages {
+        display: none;
+      }
+
+    </style>
+    <script type="text/javascript">
+      var preload_resource = ${h.literal(c.resource_json)};
+      var reclineState = ${h.literal(c.recline_state)};
+    </script>
+  </py:def>
+
+  <py:def function="page_title">
+    ${h.dataset_display_name(c.package)} /
+    ${h.resource_display_name(c.resource)} - Dataset - Resource
+  </py:def>
+
+  <div py:match="content">
+    <div class="resource-preview">
+      <div id="ckanext-datapreview"></div>
+    </div>
+    <xi:include href="../snippets/data-viewer-embed-branded-link.html" />
+  </div>
+
+  <py:def function="optional_footer">
+    <!-- data preview -->
+    <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/jquery.mustache/jquery.mustache.js')}"></script>
+    <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/flot/0.7/jquery.flot.js')}"></script>
+    <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/flot/0.7/jquery.flot.js')}"></script>
+    <script type="text/javascript" src="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.js')}"></script>
+    <script src="${h.url_for_static('/scripts/vendor/recline/recline.js')}"></script>
+  </py:def>
+
+  <xi:include href="../layout_base.html" />
+</html>
diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html
index b6c30e6..619c636 100644
--- a/ckan/templates/package/resource_read.html
+++ b/ckan/templates/package/resource_read.html
@@ -12,6 +12,7 @@
   py:strip="">
 
   <xi:include href="../_snippet/data-api-help.html" />
+  <xi:include href="../snippets/data-viewer-embed-dialog.html" />
 
   <py:def function="optional_head">
     <!-- data preview -->
@@ -20,8 +21,9 @@
     <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/leaflet/0.3.1/leaflet.ie.css')}" />
     <![endif]-->
     <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/data-explorer.css')}" />
-    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph-flot.css')}" />
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/graph.css')}" />
     <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/map.css')}" />
+    <link rel="stylesheet" href="${h.url_for_static('/scripts/vendor/recline/css/grid.css')}" />
     <style type="text/css">
       .recline-query-editor form, .recline-query-editor .text-query {
         height: 28px;
@@ -58,9 +60,23 @@
       .resource-actions .download img {
         margin: 0px 4px -4px 0;
       }
+      .preview-header {
+        padding-bottom: 13px;
+        padding-top: 0px;
+      }
+      .preview-header h3 {
+        display: inline;
+      }
+      .preview-header .btn {
+        float: right;
+        position: relative;
+        bottom: 6px;
+        padding: 8px 15px;
+      }
     </style>
     <script type="text/javascript">
       var preload_resource = ${h.literal(c.resource_json)};
+      var embedPath = "${g.site_url+h.url_for(controller='package', action='resource_embedded_dataviewer', id=c.package.id, resource_id=c.resource.id)}";
     </script>
   </py:def>
 
@@ -104,6 +120,8 @@
     ${data_api_help(c.datastore_api)}
   </py:if>
 
+  ${data_viewer_embed_dialog()}
+
     <div class="quick-info">
       <dl>
         <dt>Last updated</dt>
@@ -151,7 +169,11 @@
     </div>
 
     <div class="resource-preview">
-      <h3>Preview</h3>
+      <div class="preview-header">
+        <h3>Preview</h3>
+        <a py:if="c.pkg.is_private" title="Cannot embed as resource is private." style="display: none;" class="btn disabled" data-toggle="modal" href=".modal-data-viewer-embed-dialog">Embed</a>
+        <a py:if="not c.pkg.is_private" style="display: none;" class="btn btn-primary" data-toggle="modal" href=".modal-data-viewer-embed-dialog">Embed</a>
+      </div>
       <div id="ckanext-datapreview"></div>
     </div>
 
diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html
index e503295..cee817e 100644
--- a/ckan/templates/package/search.html
+++ b/ckan/templates/package/search.html
@@ -46,7 +46,6 @@
   <div py:match="content">
     <xi:include href="search_form.html" />
     ${field_list()}   
-    
       <py:if test="c.query_error">
         <p i18n:msg="item_count"><strong>There was an error while searching.</strong> 
             Please try again.</p>
diff --git a/ckan/templates/package/search_form.html b/ckan/templates/package/search_form.html
index 5313687..ba15882 100644
--- a/ckan/templates/package/search_form.html
+++ b/ckan/templates/package/search_form.html
@@ -5,7 +5,7 @@
   py:strip=""
   >
 
-<form id="dataset-search" class="dataset-search" action="${h.url_for(controller='package', action='search')}" method="GET">
+<form id="dataset-search" class="dataset-search" method="GET">
   <input type="search" class="search" name="q" value="${c.q}" autocomplete="off" results="0" placeholder="${_('Search...')}" />
   <span py:if="c.fields">
   <py:for each="(k, v) in c.fields"> 
diff --git a/ckan/templates/snippets/data-viewer-embed-branded-link.html b/ckan/templates/snippets/data-viewer-embed-branded-link.html
new file mode 100644
index 0000000..4bd0e45
--- /dev/null
+++ b/ckan/templates/snippets/data-viewer-embed-branded-link.html
@@ -0,0 +1,19 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml"
+  xmlns:i18n="http://genshi.edgewall.org/i18n"
+  xmlns:py="http://genshi.edgewall.org/"
+  xmlns:xi="http://www.w3.org/2001/XInclude"
+  py:strip=""
+  >
+
+<div class="branded-link">
+  <p>Powered by <a href="${h.url_for(controller='package', action='resource_read', id=c.package.id, resource_id=c.resource.id)}">
+  
+  ${g.site_title}
+  <img width="28" src="${h.url_for_static(g.site_logo)}" alt="${g.site_title} Logo" title="${g.site_title} Logo" id="logo" />
+  </a>
+  </p>
+</div>
+
+</html>
+
diff --git a/ckan/templates/snippets/data-viewer-embed-dialog.html b/ckan/templates/snippets/data-viewer-embed-dialog.html
new file mode 100644
index 0000000..b0b382d
--- /dev/null
+++ b/ckan/templates/snippets/data-viewer-embed-dialog.html
@@ -0,0 +1,31 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml"
+  xmlns:i18n="http://genshi.edgewall.org/i18n"
+  xmlns:py="http://genshi.edgewall.org/"
+  xmlns:xi="http://www.w3.org/2001/XInclude"
+  py:strip=""
+  >
+
+<div class="modal-data-viewer-embed-dialog modal fade in" style="display: none;"
+  py:def="data_viewer_embed_dialog()">
+  <div class="modal-header">
+    <a class="close" data-dismiss="modal">×</a>
+    <h3>
+      Embed Data Viewer
+    </h3>
+  </div>
+  <div class="modal-body">
+    <div>
+      <p><strong>Embed this view</strong> by copying this into your webpage:</p>
+      <textarea class="embedIframeText" style="width: 100%; height: 200px;"></textarea>
+      <p>Choose width and height in pixels:</p>
+      <label for="iframe-width">Width:</label>
+      <input class="iframe-width" name="iframe-width" value="800"/>
+      <label for="iframe-height">Height:</label>
+      <input class="iframe-height" name="iframe-height" value="500"/>
+    </div>
+    <a class="embedLink" href="">Preview</a>
+  </div>
+</div>
+
+</html>
diff --git a/ckan/tests/functional/test_pagination.py b/ckan/tests/functional/test_pagination.py
index a15f2f9..fb2e5ca 100644
--- a/ckan/tests/functional/test_pagination.py
+++ b/ckan/tests/functional/test_pagination.py
@@ -13,7 +13,7 @@ def scrape_search_results(response, object_type):
                              str(response))
     else:
         object_type = 'dataset'
-        results = re.findall('class="main-link" href="/%s/%s_(\d\d)"' % (object_type, object_type),
+        results = re.findall('href="/%s/%s_(\d\d)"' % (object_type, object_type),
                              str(response))
     return results
 
@@ -31,23 +31,6 @@ def test_scrape_user():
     res = scrape_search_results(html, 'user')
     assert_equal(res, ['00', '01'])
 
-def test_scrape_group_dataset():
-    html = '''
-        <div class="search-result ">
-          <a class="view-more-link" href="/dataset/dataset_13">View</a>
-          <a class="main-link" href="/dataset/dataset_13">dataset_13</a>
-
-          <p class="result-description"></p>
-
-          <span class="result-url">
-
-              <img src="/images/icons/lock.png" height="16px" width="16px" alt="None" />  Not Openly Licensed
-
-          </span>
-        </div>
-      '''
-    res = scrape_search_results(html, 'group_dataset')
-    assert_equal(res, ['13'])
 
 class TestPaginationPackage(TestController):
     @classmethod
diff --git a/ckan/tests/functional/test_search.py b/ckan/tests/functional/test_search.py
index fe1802c..a9a9339 100644
--- a/ckan/tests/functional/test_search.py
+++ b/ckan/tests/functional/test_search.py
@@ -108,7 +108,7 @@ def test_search_foreign_chars(self):
         res = self.app.get(offset)
         assert 'Search - ' in res
         self._check_search_results(res, u'th\xfcmb', ['<strong>1</strong>'])
-        self._check_search_results(res, 'thumb', ['<strong>0</strong>'])
+        self._check_search_results(res, 'thumb', ['<strong>1</strong>'])
 
     @search_related
     def test_search_escape_chars(self):
diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py
index d32fd89..61fab80 100644
--- a/ckan/tests/lib/test_dictization.py
+++ b/ckan/tests/lib/test_dictization.py
@@ -80,6 +80,7 @@ def setup_class(cls):
                             u'size': None,
                             u'size_extra': u'123',
                             u'state': u'active',
+                            u'tracking_summary': {'total': 0, 'recent': 0},
                             u'url': u'http://www.annakarenina.com/download/x=1&y=2',
                             u'webstore_last_updated': None,
                             u'webstore_url': None},
@@ -98,6 +99,7 @@ def setup_class(cls):
                             u'size': None,
                             u'size_extra': u'345',
                             u'state': u'active',
+                            u'tracking_summary': {'total': 0, 'recent': 0},
                             u'url': u'http://www.annakarenina.com/index.json',
                             u'webstore_last_updated': None,
                             u'webstore_url': None}],
@@ -110,6 +112,7 @@ def setup_class(cls):
                      {'name': u'tolstoy', 'display_name': u'tolstoy',
                          'state': u'active'}],
             'title': u'A Novel By Tolstoy',
+            'tracking_summary': {'total': 0, 'recent': 0},
             'url': u'http://www.annakarenina.com',
             'version': u'0.7a'}
 
@@ -194,6 +197,7 @@ def test_01_dictize_main_objects_simple(self):
              'size': None,
              u'size_extra': u'123',
              'state': u'active',
+            u'tracking_summary': {'total': 0, 'recent': 0},
              'url': u'http://www.annakarenina.com/download/x=1&y=2',
              'webstore_last_updated': None,
              'webstore_url': None
@@ -706,6 +710,7 @@ def test_13_get_package_in_past(self):
             u'resource_type': None,
             u'size': None,
             u'state': u'active',
+            u'tracking_summary': {'total': 0, 'recent': 0},
             u'url': u'newurl',
             u'webstore_last_updated': None,
             u'webstore_url': None})
@@ -735,6 +740,7 @@ def test_14_resource_no_id(self):
             'hash': u'abc123',
             'description': u'Full text. Needs escaping: " Umlaut: \xfc',
             'format': u'plain text',
+            'tracking_summary': {'recent': 0, 'total': 0},
             'url': u'test_new',
             'cache_url': None,
             'webstore_url': None,
diff --git a/ckan/tests/lib/test_resource_search.py b/ckan/tests/lib/test_resource_search.py
index 8562bf3..3d44757 100644
--- a/ckan/tests/lib/test_resource_search.py
+++ b/ckan/tests/lib/test_resource_search.py
@@ -120,7 +120,7 @@ def test_12_search_all_fields(self):
         assert isinstance(res_dict, dict)
         res_keys = set(res_dict.keys())
         expected_res_keys = set(model.Resource.get_columns())
-        expected_res_keys.update(['id', 'resource_group_id', 'package_id', 'position', 'size_extra'])
+        expected_res_keys.update(['id', 'resource_group_id', 'package_id', 'position', 'size_extra', 'tracking_summary'])
         assert_equal(res_keys, expected_res_keys)
         pkg1 = model.Package.by_name(u'pkg1')
         ab = pkg1.resources[0]
diff --git a/ckan/tests/lib/test_solr_package_search.py b/ckan/tests/lib/test_solr_package_search.py
index 6ec2b2f..75d54c0 100644
--- a/ckan/tests/lib/test_solr_package_search.py
+++ b/ckan/tests/lib/test_solr_package_search.py
@@ -292,7 +292,7 @@ def test_search_foreign_chars(self):
         result = search.query_for(model.Package).run({'q': 'umlaut'})
         assert result['results'] == ['gils'], result['results']
         result = search.query_for(model.Package).run({'q': u'thumb'})
-        assert result['count'] == 0, result['results']
+        assert result['results'] == ['gils'], result['results']
         result = search.query_for(model.Package).run({'q': u'th\xfcmb'})
         assert result['results'] == ['gils'], result['results']
 
diff --git a/ckanext/multilingual/solr/schema.xml b/ckanext/multilingual/solr/schema.xml
index 8475187..fb957d3 100644
--- a/ckanext/multilingual/solr/schema.xml
+++ b/ckanext/multilingual/solr/schema.xml
@@ -16,7 +16,7 @@
  limitations under the License.
 -->
 
-<schema name="ckan" version="1.3">
+<schema name="ckan" version="1.4">
 
 <types>
     <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
@@ -373,6 +373,8 @@
     <field name="tags" type="string" indexed="true" stored="true" multiValued="true"/>
     <field name="groups" type="string" indexed="true" stored="true" multiValued="true"/>
 
+    <field name="capacity" type="string" indexed="true" stored="true" multiValued="false"/>
+
     <field name="res_description" type="textgen" indexed="true" stored="true" multiValued="true"/>
     <field name="res_format" type="string" indexed="true" stored="true" multiValued="true"/>
     <field name="res_url" type="string" indexed="true" stored="true" multiValued="true"/>
@@ -390,11 +392,19 @@
     <field name="linked_from" type="text" indexed="true" stored="false" multiValued="true"/>
     <field name="child_of" type="text" indexed="true" stored="false" multiValued="true"/>
     <field name="parent_of" type="text" indexed="true" stored="false" multiValued="true"/>
+    <field name="views_total" type="int" indexed="true" stored="false"/>
+    <field name="views_recent" type="int" indexed="true" stored="false"/>
+    <field name="resources_accessed_total" type="int" indexed="true" stored="false"/>
+    <field name="resources_accessed_recent" type="int" indexed="true" stored="false"/>
 
     <field name="metadata_created" type="date" indexed="true" stored="true" multiValued="false"/>
     <field name="metadata_modified" type="date" indexed="true" stored="true" multiValued="false"/>
 
     <field name="indexed_ts" type="date" indexed="true" stored="true" default="NOW" multiValued="false"/>
+
+    <!-- Copy the title field into titleString, and treat as a string
+         (rather than text type).  This allows us to sort on the titleString -->
+    <field name="title_string" type="string" indexed="true" stored="false" />
      
     <!-- Multilingual -->
     <field name="text_en" type="text_en" indexed="true" stored="true"/>
@@ -424,6 +434,8 @@
     <field name="text_pl" type="text_pl" indexed="true" stored="true"/>
     <field name="title_pl" type="text_pl" indexed="true" stored="true"/>
 
+    <field name="data_dict" type="string" indexed="false" stored="true" />
+
     <dynamicField name="extras_*" type="text" indexed="true" stored="true" multiValued="false"/>
     <dynamicField name="*" type="string" indexed="true"  stored="false"/>
 </fields>
diff --git a/doc/index.rst b/doc/index.rst
index b1c71f0..601d4c7 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -2,6 +2,10 @@
 Welcome to CKAN's Administration Guide
 =======================================
 
+.. note ::
+
+   This is the documentation for CKAN version '|version|'. If you are using a different version, use the links on the bottom right corner of the page to select the appropriate documentation.
+
 This Administration Guide covers how to set up and manage `CKAN <http://ckan.org>`_ software. 
 
 * The first two sections cover your two options for installing CKAN: package or source install.
diff --git a/doc/using-data-api.rst b/doc/using-data-api.rst
index 09caf21..50c7db2 100644
--- a/doc/using-data-api.rst
+++ b/doc/using-data-api.rst
@@ -2,22 +2,44 @@
 Using the Data API
 ==================
 
+The following provides an introduction to using the CKAN :doc:`DataStore
+<datastore>` Data API.
+
 Introduction
 ============
 
-The Data API builds directly on ElasticSearch, with a resource API endpoint
-being equivalent to a single index 'type' in ElasticSearch (we tend to refer to
-it as a 'table').  This means you can often directly re-use `ElasticSearch
-client libraries`_ when connecting to the API endpoint.
+Each 'table' in the DataStore is an ElasticSearch_ index type ('table'). As
+such the Data API for each CKAN resource is directly equivalent to a single
+index 'type' in ElasticSearch (we tend to refer to it as a 'table').
+
+This means you can (usually) directly re-use `ElasticSearch client libraries`_
+when connecting to a Data API endpoint. It also means that what follows is, in
+essence, a tutorial in using the ElasticSearch_ API.
+
+The following short set of slides provide a brief overview and introduction to
+the DataStore and the Data API.
 
-Furthermore, it means that what is presented below is essentially a tutorial in the ElasticSearch API.
+.. raw:: html
 
+   <iframe src="https://docs.google.com/presentation/embed?id=1UhEqvEPoL_VWO5okYiEPfZTLcLYWqtvRRmB1NBsWXY8&start=false&loop=false&delayms=3000" frameborder="0" width="480" height="389" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>
+
+.. _ElasticSearch: http://elasticsearch.org/
 .. _ElasticSearch client libraries: http://www.elasticsearch.org/guide/appendix/clients.html
 
 Quickstart
 ==========
 
-``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index / table).
+``{{endpoint}}`` refers to the data API endpoint (or ElasticSearch index /
+table). For example, on the DataHub_ this gold prices data resource
+http://datahub.io/dataset/gold-prices/resource/b9aae52b-b082-4159-b46f-7bb9c158d013
+would have its Data API endpoint at:
+http://datahub.io/api/data/b9aae52b-b082-4159-b46f-7bb9c158d013. If you were
+just using ElasticSearch standalone an example of an endpoint would be:
+http://localhost:9200/gold-prices/monthly-price-table.
+
+.. note::  every resource on a CKAN instance for which a DataStore table is
+           enabled provides links to its Data API endpoint via the Data API
+           button at the top right of the resource page.
 
 Key urls:
 
@@ -28,6 +50,8 @@ Key urls:
 
 * Schema (Mapping): ``{{endpoint}}/_mapping``
 
+.. _DataHub: http://datahub.io/
+
 Examples
 --------
 
diff --git a/pip-requirements.txt b/pip-requirements.txt
index 4460f14..9ff9d10 100644
--- a/pip-requirements.txt
+++ b/pip-requirements.txt
@@ -5,11 +5,11 @@
 # 
 #     pip install --ignore-installed -r pip-requirements.txt
 
--e git+https://github.com/okfn/ckan@release-v1.6.1#egg=ckan
+-e git+https://github.com/okfn/ckan@master#egg=ckan
 # CKAN dependencies
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_conflict.txt
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_present.txt
--r https://github.com/okfn/ckan/raw/release-v1.6.1/requires/lucid_missing.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_conflict.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_present.txt
+-r https://github.com/okfn/ckan/raw/master/requires/lucid_missing.txt
 
 # NOTE: Developers, please do not edit this file. Changes should go in the
 #       appropriate files in the `requires' directory.
diff --git a/setup.py b/setup.py
index cd37609..d1e857d 100644
--- a/setup.py
+++ b/setup.py
@@ -71,6 +71,7 @@
     roles = ckan.lib.authztool:RolesCommand
     celeryd = ckan.lib.cli:Celery
     rdf-export = ckan.lib.cli:RDFExport
+    tracking = ckan.lib.cli:Tracking
     plugin-info = ckan.lib.cli:PluginInfo
 
     [console_scripts]


================================================================
  Commit: 71887e3d90d0c683f7dad5153311fda20ef361df
      https://github.com/okfn/ckan/commit/71887e3d90d0c683f7dad5153311fda20ef361df
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-26 (Thu, 26 Apr 2012)

  Changed paths:
    M ckan/lib/alphabet_paginate.py
    M ckan/public/css/style.css
    M ckan/templates/tag/index.html
    M ckan/templates/user/list.html
    M ckan/templates/user/login.html
    M ckan/templates/user/logout.html
    M ckan/tests/functional/test_user.py
    M ckan/tests/lib/test_alphabet_pagination.py
    M doc/solr-setup.rst

  Log Message:
  -----------
  Merge branch 'master' of https://github.com/okfn/ckan


diff --git a/ckan/lib/alphabet_paginate.py b/ckan/lib/alphabet_paginate.py
index 0afa88f..b75bf27 100644
--- a/ckan/lib/alphabet_paginate.py
+++ b/ckan/lib/alphabet_paginate.py
@@ -1,6 +1,7 @@
 '''
-Based on webhelpers.paginator, but each page is for items beginning
- with a particular letter.
+Based on webhelpers.paginator, but:
+ * each page is for items beginning with a particular letter
+ * output is suitable for Bootstrap
 
  Example:
         c.page = h.Page(
@@ -43,7 +44,12 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho
         self.other_text = other_text
         self.paging_threshold = paging_threshold
         self.controller_name = controller_name
-        self.available = dict( (c,0,) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" )
+
+        self.letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text]
+        
+        # Work out which alphabet letters are 'available' i.e. have some results
+        # because we grey-out those which aren't.
+        self.available = dict( (c,0,) for c in self.letters )
         for c in self.collection:
             if isinstance(c, unicode):
                 x = c[0]
@@ -51,35 +57,42 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho
                 x = c[self.alpha_attribute][0]
             else:
                 x = getattr(c, self.alpha_attribute)[0]
+            x = x.upper()
+            if x not in self.letters:
+                x = self.other_text
             self.available[x] = self.available.get(x, 0) + 1
 
     def pager(self, q=None):
         '''Returns pager html - for navigating between the pages.
            e.g. Something like this:
-             <div class='pager'>
-                 <span class="pager_curpage">A</span>
-                 <a class="pager_link" href="/package/list?page=B">B</a>
-                 <a class="pager_link" href="/package/list?page=C">C</a>
+             <ul class='pagination pagination-alphabet'>
+                 <li class="active"><a href="/package/list?page=A">A</a></li>
+                 <li><a href="/package/list?page=B">B</a></li>
+                 <li><a href="/package/list?page=C">C</a></li>
                     ...
-                 <a class="pager_link" href="/package/list?page=Z">Z</a
-                 <a class="pager_link" href="/package/list?page=Other">Other</a
-             </div>
+                 <li class="disabled"><a href="/package/list?page=Z">Z</a></li>
+                 <li><a href="/package/list?page=Other">Other</a></li>
+             </ul>
         '''
         if self.item_count < self.paging_threshold:
             return ''
         pages = []
         page = q or self.page
-        letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text]
-        for letter in letters:
+        for letter in self.letters:
+            href = url_for(controller=self.controller_name, action='index', page=letter)
+            link = HTML.a(href=href, c=letter)
             if letter != page:
                 if self.available.get(letter, 0):
-                    page_element = HTML.a(class_='pager_link', href=url_for(controller=self.controller_name, action='index', page=letter),c=letter)
+                    li_class = ''
                 else:
-                    page_element = HTML.span(class_="pager_empty", c=letter)
+                    li_class = 'disabled'
             else:
-                page_element = HTML.span(class_='pager_curpage', c=letter)
+                li_class = 'active'
+            attributes = {'class_': li_class} if li_class else {}
+            page_element = HTML.li(link, **attributes)
             pages.append(page_element)
-        div = HTML.tag('div', class_='pager', *pages)
+        ul = HTML.tag('ul', *pages)
+        div = HTML.div(ul, class_='pagination pagination-alphabet')
         return div
 
 
diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 7e9e609..283f5d3 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -379,28 +379,8 @@ ul.no-break li {
 /* ============== */
 /* = Pagination = */
 /* ============== */
-.pager {
-	width: 100%;
-	text-align: center;
-	margin: 0 0 1.2em 0;
-	clear: both;
-}
-.pager span, .pager a {
-	text-decoration: none;
-	margin: 0em;
-	border: none;
-	padding: 0.3em 0.1em;
-}
-.pager a:hover, .pager a:active {
-	color: #fff;
-	background-color: #c22;
-}
-.pager span.pager_dotdot {
-	color: #aaa;
-}
-.pager span.pager_curpage {
-	font-weight: bold;
-	border: 1px solid #ddd;
+.pagination-alphabet a { 
+        padding: 0 6px;
 }
 
 /* ====== */
diff --git a/ckan/templates/tag/index.html b/ckan/templates/tag/index.html
index 17292e9..8c383c8 100644
--- a/ckan/templates/tag/index.html
+++ b/ckan/templates/tag/index.html
@@ -8,9 +8,9 @@
   <div py:match="content">
     <h2>Tags</h2>
 
-    <form id="tag-search" action="" method="GET">
+    <form class="form-inline" id="tag-search" action="" method="GET">
       <input type="text" id="q" name="q" value="${c.q}" />
-      <input type="submit" name="search" value="${_('Search')} »" />
+      <input class="btn btn-primary" type="submit" name="search" value="${_('Search')} »" />
     </form>
     
     <hr />
diff --git a/ckan/templates/user/list.html b/ckan/templates/user/list.html
index 3c736b4..1ef2655 100644
--- a/ckan/templates/user/list.html
+++ b/ckan/templates/user/list.html
@@ -8,9 +8,10 @@
   
   <py:match path="primarysidebar">
     <li class="widget-container widget_text">
-      <form id="user-search" class="user-search" action="" method="GET">
-        <input type="text" id="q" name="q" value="${c.q}" />
-        <input type="submit" class="btn btn-small" name="" value="${_('Search')} »" />
+      <h3>Search Users</h3>
+      <form id="user-search" class="form-inline user-search" action="" method="GET">
+        <input type="text" class="input-medium" id="q" name="q" value="${c.q}" />
+        <input type="submit" class="btn btn-small btn-primary" name="" value="${_('Search')} »" />
       </form>
       <p py:if="c.q" i18n:msg="item_count">
         <strong>${c.page.item_count}</strong> users found.
diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html
index 6cabb81..9fdf3df 100644
--- a/ckan/templates/user/login.html
+++ b/ckan/templates/user/login.html
@@ -18,25 +18,39 @@
   
   <py:def function="page_title">Login - User</py:def>
   <py:def function="page_heading">Login to ${g.site_title}</py:def>
+  <py:def function="body_class">no-sidebar</py:def>
 
   <div py:match="content">
-    
-    <form action="${h.url_for('/login_generic')}" method="post" class="simple-form" id="login">  
+
+    <form action="${h.url_for('/login_generic')}" method="post" class="form-horizontal" id="login">  
       <fieldset>
         <!--legend i18n:msg="site_title">Login</legend-->
+        <div class="control-group">
+          <label class="control-label" for="login">Login:</label>
+          <div class="controls">
+            <input type="text" class="input-xlarge" name="login" id="login" value="" />
+          </div>
+        </div>
+        <div class="control-group">
+          <label class="control-label" for="password">Password:</label>
+          <div class="controls">
+            <input type="password" name="password" id="password" value="" />
+          </div>
+        </div>
+        <div class="control-group">
+          <label class="control-label" for="remember">Remember me:</label>
+          <!-- optional 2 year cookie expiry -->
+          <div class="controls">
+            <input type="checkbox" name="remember" id="remember" value="63072000" checked="checked"/>
+          </div>
+        </div>        
 
-        <label for="login">Login:</label>
-        <input type="text" name="login" value="" />
-        <br/>
-        <label for="password">Password:</label>
-        <input type="password" name="password" value="" />
-        <!-- 50 year timeout -->
-        <input type="hidden" name="remember" value="1576800000" />
-        <br/>
+        <div class="form-actions">
+          <button name="s" id="s" type="submit" class="btn btn-primary">${_('Sign In')}</button>
+          — 
+          <a href="${h.url_for('reset')}">Forgot your password?</a>
+        </div>
       </fieldset>
-      <input name="s" id="s" type="submit" class="btn primary" value="${_('Sign In')}"/>
-      — 
-      <a href="${h.url_for(controller='user', action='request_reset')}">Forgot your password?</a>
     </form>
     <br/> 
     <!-- Simple OpenID Selector -->
diff --git a/ckan/templates/user/logout.html b/ckan/templates/user/logout.html
index e40ec5c..0f37272 100644
--- a/ckan/templates/user/logout.html
+++ b/ckan/templates/user/logout.html
@@ -6,6 +6,7 @@
 
   <py:def function="page_title">Logout</py:def>
   <py:def function="page_heading">Logout from ${g.site_title}</py:def>
+  <py:def function="body_class">no-sidebar</py:def>
 
   <div py:match="content">
     <p>You have logged out successfully.</p>
diff --git a/ckan/tests/functional/test_user.py b/ckan/tests/functional/test_user.py
index 5ab1efc..eb44bd9 100644
--- a/ckan/tests/functional/test_user.py
+++ b/ckan/tests/functional/test_user.py
@@ -168,11 +168,14 @@ def test_login(self):
         fv = res.forms['login']
         fv['login'] = str(username)
         fv['password'] = str(password)
+        fv['remember'] = False
         res = fv.submit()
 
         # check cookies set
         cookies = self._get_cookie_headers(res)
         assert cookies
+        for cookie in cookies:
+            assert not 'max-age' in cookie.lower(), cookie
 
         # first get redirected to user/logged_in
         assert_equal(res.status, 302)
@@ -206,6 +209,32 @@ def test_login(self):
         print res
         assert 'testlogin' in res.body, res.body
 
+    def test_login_remembered(self):
+        # create test user
+        username = u'testlogin2'
+        password = u'letmein'
+        CreateTestData.create_user(name=username,
+                                   password=password)
+        user = model.User.by_name(username)
+
+        # do the login
+        offset = url_for(controller='user', action='login')
+        res = self.app.get(offset)
+        fv = res.forms['login']
+        fv['login'] = str(username)
+        fv['password'] = str(password)
+        fv['remember'] = True
+        res = fv.submit()
+
+        # check cookies set
+        cookies = self._get_cookie_headers(res)
+        assert cookies
+        # check cookie is remembered via Max-Age and Expires
+        # (both needed for cross-browser compatibility)
+        for cookie in cookies:
+            assert 'Max-Age=63072000;' in cookie, cookie
+            assert 'Expires=' in cookie, cookie
+
     def test_login_wrong_password(self):
         # create test user
         username = u'testloginwrong'
diff --git a/ckan/tests/lib/test_alphabet_pagination.py b/ckan/tests/lib/test_alphabet_pagination.py
index a1bf6ae..8afc276 100644
--- a/ckan/tests/lib/test_alphabet_pagination.py
+++ b/ckan/tests/lib/test_alphabet_pagination.py
@@ -1,5 +1,7 @@
 import re
 
+from nose.tools import assert_equal
+
 from ckan.tests import *
 from ckan.tests import regex_related
 from ckan.lib.create_test_data import CreateTestData
@@ -28,6 +30,16 @@ def setup_class(cls):
     def teardown_class(cls):
         model.repo.rebuild_db()
 
+    def test_00_model(self):
+        query = model.Session.query(model.Package)
+        page = h.AlphaPage(
+            collection=query,
+            alpha_attribute='title',
+            page='A',
+            other_text=other,
+        )
+        assert_equal(page.available, {'Other': 20, 'A': 10, 'C': 10, 'B': 10, 'E': 0, 'D': 10, 'G': 0, 'F': 0, 'I': 0, 'H': 0, 'K': 0, 'J': 0, 'M': 0, 'L': 0, 'O': 0, 'N': 0, 'Q': 0, 'P': 0, 'S': 0, 'R': 0, 'U': 0, 'T': 0, 'W': 0, 'V': 0, 'Y': 0, 'X': 0, 'Z': 0})
+
     def test_01_package_page(self):
         query = model.Session.query(model.Package)
         page = h.AlphaPage(
@@ -37,11 +49,12 @@ def test_01_package_page(self):
             other_text=other,
         )
         pager = page.pager()
-        assert pager.startswith('<div class="pager">'), pager
-        assert '<span class="pager_curpage">A</span>' in pager, pager
+        assert pager.startswith('<div class="pagination pagination-alphabet">'), pager
+        assert '<li class="active"><a href="/tag?page=A">A</a></li>' in pager, pager
         url_base = '/packages'
-        assert re.search('\<span class="pager_empty"\>B\<\/span\>', pager), pager
-        assert re.search('\<span class="pager_empty"\>Other\<\/span\>', pager), pager
+        assert re.search(r'\<li\>\<a href="\/tag\?page=B"\>B\<\/a\>\<\/li\>', pager), pager
+        assert re.search(r'\<li class="disabled"\>\<a href="\/tag\?page=E"\>E\<\/a\>\<\/li\>', pager), pager
+        assert re.search(r'\<li\>\<a href="\/tag\?page=Other"\>Other\<\/a\>\<\/li\>', pager), pager
 
 
     def test_02_package_items(self):
diff --git a/doc/solr-setup.rst b/doc/solr-setup.rst
index 4529797..3c158b2 100644
--- a/doc/solr-setup.rst
+++ b/doc/solr-setup.rst
@@ -71,7 +71,7 @@ so, create a symbolic link to the schema file in the config folder. Use the late
 supported by the CKAN version you are installing (it will generally be the highest one)::
 
  sudo mv /etc/solr/conf/schema.xml /etc/solr/conf/schema.xml.bak
- sudo ln -s ~/ckan/ckan/config/solr/schema-1.3.xml /etc/solr/conf/schema.xml
+ sudo ln -s ~/ckan/ckan/config/solr/schema-1.4.xml /etc/solr/conf/schema.xml
 
 Now restart jetty::
 
@@ -93,6 +93,7 @@ will have different paths in the Solr server URL::
 
  http://localhost:8983/solr/ckan-schema-1.2       # Used by CKAN up to 1.5
  http://localhost:8983/solr/ckan-schema-1.3       # Used by CKAN 1.5.1
+ http://localhost:8983/solr/ckan-schema-1.4       # Used by CKAN 1.7
  http://localhost:8983/solr/some-other-site  # Used by another site
 
 To set up a multicore Solr instance, repeat the steps on the previous section


================================================================
  Commit: 62825e578c7bdb8942f45f1ffaf40169019225c1
      https://github.com/okfn/ckan/commit/62825e578c7bdb8942f45f1ffaf40169019225c1
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-26 (Thu, 26 Apr 2012)

  Changed paths:
    M ckan/controllers/package.py

  Log Message:
  -----------
  [xs,bug] Fixed the non appearance of current related item count on resource pages


diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index ba12b86..9e99e0f 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -480,6 +480,7 @@ def edit(self, id, data=None, errors=None, error_summary=None):
         c.errors_json = json.dumps(errors)
 
         self._setup_template_variables(context, {'id': id}, package_type=package_type)
+        c.related_count = len(c.pkg.related)
 
         # TODO: This check is to maintain backwards compatibility with the old way of creating
         # custom forms. This behaviour is now deprecated.
@@ -749,6 +750,8 @@ def resource_read(self, id, resource_id):
             c.package['isopen'] = False
         c.datastore_api = h.url_for('datastore_read', id=c.resource.get('id'),
                 qualified=True)
+
+        c.related_count = len(c.pkg.related)
         return render('package/resource_read.html')
 
     def resource_embedded_dataviewer(self, id, resource_id):


================================================================
  Commit: afb4555e427d1ee9b1678f6098b54bc261f248bb
      https://github.com/okfn/ckan/commit/afb4555e427d1ee9b1678f6098b54bc261f248bb
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-26 (Thu, 26 Apr 2012)

  Changed paths:
    M ckan/logic/auth/delete.py
    M ckan/logic/auth/publisher/delete.py
    M ckan/templates/_util.html
    M ckan/templates/package/related_list.html

  Log Message:
  -----------
  Fix for a bug related to deleting related items


diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py
index c99bec3..c324049 100644
--- a/ckan/logic/auth/delete.py
+++ b/ckan/logic/auth/delete.py
@@ -27,6 +27,15 @@ def related_delete(context, data_dict):
 
     related = get_related_object(context, data_dict)
     userobj = model.User.get( user )
+
+    if related.datasets:
+        package = related.datasets[0]
+
+        pkg_dict = { 'id': package.id }
+        authorized = package_delete(context, pkg_dict).get('success')
+        if authorized:
+            return {'success': True}
+
     if not userobj or userobj.id != related.owner_id:
         return {'success': False, 'msg': _('Only the owner can delete a related item')}
 
diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py
index 9d3388f..0aaa0d9 100644
--- a/ckan/logic/auth/publisher/delete.py
+++ b/ckan/logic/auth/publisher/delete.py
@@ -40,6 +40,12 @@ def related_delete(context, data_dict):
 
     related = get_related_object(context, data_dict)
     userobj = model.User.get( user )
+
+    if related.datasets:
+        package = related.datasets[0]
+        if _groups_intersect( userobj.get_groups('organization'), package.get_groups('organization') ):
+            return {'success': True}
+
     if not userobj or userobj.id != related.owner_id:
         return {'success': False, 'msg': _('Only the owner can delete a related item')}
 
diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html
index 8e320d8..5aa7c07 100644
--- a/ckan/templates/_util.html
+++ b/ckan/templates/_util.html
@@ -128,6 +128,7 @@
   <py:def function="related_summary(related)">
     <li class="span3">
       <div class="thumbnail">
+        <button py:if="c.user and (c.userobj.id == related.owner_id or h.check_access('package_update',{'id':c.pkg.id}))" class="close" onclick="related_delete('${related.id}');">×</button>
         <a href="${related.url}" class="image">
           <img src="${related.image_url}" width="210" py:if="related.image_url" />
           <img src="/images/photo-placeholder.png" width="210" py:if="not related.image_url" />
diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html
index 49a75ea..baced80 100644
--- a/ckan/templates/package/related_list.html
+++ b/ckan/templates/package/related_list.html
@@ -40,6 +40,25 @@
   </div>
 
   <py:def function="optional_head">
+    <script type="text/javascript" py:if="c.user">
+        function related_delete(related_id) {
+          var data = { 'id' : related_id }
+          $.ajax({
+            type: "post",
+            url: CKAN.SITE_URL + '/api/3/action/related_delete',
+            data: JSON.stringify(data),
+            success: function (data) {
+              window.location.reload();
+            },
+            error: function(err, txt, w) {
+              // This needs to be far more informative.
+              var msg = '<strong>Error:</strong> Unable to delete related item';
+              $('<div class="alert alert-error" />').html(msg).hide().prependTo($('div#main')).fadeIn();
+            }
+          });
+
+        }
+    </script>
     <py:if test="config.get('rdf_packages')">
       <link rel="alternate" type="application/rdf+xml" title="RDF/XML" href="${config['rdf_packages'] + '/' + c.pkg.id + '.rdf' }" />
       <link rel="alternate" type="application/turtle" title="RDF/Turtle" href="${config['rdf_packages'] + '/' + c.pkg.id + '.ttl' }" />


================================================================
Compare: https://github.com/okfn/ckan/compare/ddba520...afb4555


More information about the ckan-changes mailing list