First commit
authorThierry Florac <thierry.florac@onf.fr>
Wed, 11 Mar 2015 12:27:00 +0100
changeset 0 a02202f95e2c
child 1 3dc2a116e185
First commit
.hgignore
.installed.cfg
LICENSE
MANIFEST.in
bootstrap.py
buildout.cfg
docs/HISTORY.txt
docs/README.txt
setup.py
src/pyams_zodbbrowser.egg-info/PKG-INFO
src/pyams_zodbbrowser.egg-info/SOURCES.txt
src/pyams_zodbbrowser.egg-info/dependency_links.txt
src/pyams_zodbbrowser.egg-info/entry_points.txt
src/pyams_zodbbrowser.egg-info/namespace_packages.txt
src/pyams_zodbbrowser.egg-info/not-zip-safe
src/pyams_zodbbrowser.egg-info/requires.txt
src/pyams_zodbbrowser.egg-info/top_level.txt
src/pyams_zodbbrowser/__init__.py
src/pyams_zodbbrowser/btreesupport.py
src/pyams_zodbbrowser/cache.py
src/pyams_zodbbrowser/configure.zcml
src/pyams_zodbbrowser/diff.py
src/pyams_zodbbrowser/doctests/README.txt
src/pyams_zodbbrowser/history.py
src/pyams_zodbbrowser/interfaces/__init__.py
src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.mo
src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.po
src/pyams_zodbbrowser/locales/pyams_zodbbrowser.pot
src/pyams_zodbbrowser/state.py
src/pyams_zodbbrowser/tests/__init__.py
src/pyams_zodbbrowser/tests/test_utilsdocs.py
src/pyams_zodbbrowser/tests/test_utilsdocstrings.py
src/pyams_zodbbrowser/value.py
src/pyams_zodbbrowser/zmi/__init__.py
src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.css
src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.min.css
src/pyams_zodbbrowser/zmi/resources/img/collapse.png
src/pyams_zodbbrowser/zmi/resources/img/expand.png
src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.js
src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.min.js
src/pyams_zodbbrowser/zmi/templates/zodbhistory.pt
src/pyams_zodbbrowser/zmi/templates/zodbinfo.pt
src/pyams_zodbbrowser/zmi/views.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,19 @@
+
+syntax: regexp
+^develop-eggs$
+syntax: regexp
+^parts$
+syntax: regexp
+^bin$
+syntax: regexp
+^\.installed\.cfg$
+syntax: regexp
+^\.settings$
+syntax: regexp
+^build$
+syntax: regexp
+^dist$
+syntax: regexp
+^\.idea$
+syntax: regexp
+.*\.pyc$
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.installed.cfg	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,71 @@
+[buildout]
+installed_develop_eggs = 
+parts = package i18n pyflakes test
+
+[package]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pserve
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/ptweens
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pshell
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/prequest
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pviews
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/proutes
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pcreate
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pdistreport
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-12.3-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+eggs = pyams_zodbbrowser
+	pyramid
+	zope.component
+	zope.interface
+eggs-directory = /var/local/env/pyams/eggs
+recipe = zc.recipe.egg
+
+[i18n]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pybabel
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/polint
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pot-create
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-12.3-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+eggs = babel
+	lingua
+eggs-directory = /var/local/env/pyams/eggs
+recipe = zc.recipe.egg
+
+[pyflakes]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pyflakes
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/pyflakes
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-12.3-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+eggs = pyflakes
+eggs-directory = /var/local/env/pyams/eggs
+entry-points = pyflakes=pyflakes.scripts.pyflakes:main
+initialization = if not sys.argv[1:]: sys.argv[1:] = ["src"]
+recipe = zc.recipe.egg
+scripts = pyflakes
+
+[test]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/parts/test
+	/home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/test
+__buildout_signature__ = zc.recipe.testrunner-2.0.0-py3.4.egg zc.recipe.egg-2.0.1-py3.4.egg setuptools-12.3-py3.4.egg zope.testrunner-4.4.6-py3.4.egg zc.buildout-2.3.1-py3.4.egg zope.interface-4.1.2-py3.4-linux-x86_64.egg zope.exceptions-4.0.7-py3.4.egg six-1482e89f68d85eea27f4ed7749df2819
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/develop-eggs
+eggs = pyams_zodbbrowser [test]
+eggs-directory = /var/local/env/pyams/eggs
+location = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/parts/test
+recipe = zc.recipe.testrunner
+script = /home/tflorac/Dropbox/src/PyAMS/pyams_zodbbrowser/bin/test
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,42 @@
+Zope Public License (ZPL) Version 2.1
+=====================================
+
+A copyright notice accompanies this license document that identifies
+the copyright holders.
+
+This license has been certified as open source. It has also been designated
+as GPL compatible by the Free Software Foundation (FSF).
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+   1. Redistributions in source code must retain the accompanying copyright
+      notice, this list of conditions, and the following disclaimer.
+   2. Redistributions in binary form must reproduce the accompanying copyright
+      notice, this list of conditions, and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+   3. Names of the copyright holders must not be used to endorse or promote
+      products derived from this software without prior written permission
+      from the copyright holders.
+   4. The right to distribute this software or to use it for any purpose does
+      not give you the right to use Servicemarks (sm) or Trademarks (tm) of the
+      copyright holders. Use of them is covered by separate agreement with the
+      copyright holders.
+   5. If any files are modified, you must cause the modified files to carry
+      prominent notices stating that you changed the files and the date of any
+      change.
+
+
+Disclaimer
+==========
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
+EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MANIFEST.in	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,5 @@
+include *.txt
+recursive-include docs *
+recursive-include src *
+global-exclude *.pyc
+global-exclude *.*~
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bootstrap.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,178 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Bootstrap a buildout-based project
+
+Simply run this script in a directory containing a buildout.cfg.
+The script accepts buildout command-line options, so you can
+use the -c option to specify an alternate configuration file.
+"""
+
+import os
+import shutil
+import sys
+import tempfile
+
+from optparse import OptionParser
+
+tmpeggs = tempfile.mkdtemp()
+
+usage = '''\
+[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options]
+
+Bootstraps a buildout-based project.
+
+Simply run this script in a directory containing a buildout.cfg, using the
+Python that you want bin/buildout to use.
+
+Note that by using --find-links to point to local resources, you can keep 
+this script from going over the network.
+'''
+
+parser = OptionParser(usage=usage)
+parser.add_option("-v", "--version", help="use a specific zc.buildout version")
+
+parser.add_option("-t", "--accept-buildout-test-releases",
+                  dest='accept_buildout_test_releases',
+                  action="store_true", default=False,
+                  help=("Normally, if you do not specify a --version, the "
+                        "bootstrap script and buildout gets the newest "
+                        "*final* versions of zc.buildout and its recipes and "
+                        "extensions for you.  If you use this flag, "
+                        "bootstrap and buildout will get the newest releases "
+                        "even if they are alphas or betas."))
+parser.add_option("-c", "--config-file",
+                  help=("Specify the path to the buildout configuration "
+                        "file to be used."))
+parser.add_option("-f", "--find-links",
+                  help=("Specify a URL to search for buildout releases"))
+parser.add_option("--allow-site-packages",
+                  action="store_true", default=False,
+                  help=("Let bootstrap.py use existing site packages"))
+
+
+options, args = parser.parse_args()
+
+######################################################################
+# load/install setuptools
+
+try:
+    if options.allow_site_packages:
+        import setuptools
+        import pkg_resources
+    from urllib.request import urlopen
+except ImportError:
+    from urllib2 import urlopen
+
+ez = {}
+exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez)
+
+if not options.allow_site_packages:
+    # ez_setup imports site, which adds site packages
+    # this will remove them from the path to ensure that incompatible versions 
+    # of setuptools are not in the path
+    import site
+    # inside a virtualenv, there is no 'getsitepackages'. 
+    # We can't remove these reliably
+    if hasattr(site, 'getsitepackages'):
+        for sitepackage_path in site.getsitepackages():
+            sys.path[:] = [x for x in sys.path if sitepackage_path not in x]
+
+setup_args = dict(to_dir=tmpeggs, download_delay=0)
+ez['use_setuptools'](**setup_args)
+import setuptools
+import pkg_resources
+
+# This does not (always?) update the default working set.  We will
+# do it.
+for path in sys.path:
+    if path not in pkg_resources.working_set.entries:
+        pkg_resources.working_set.add_entry(path)
+
+######################################################################
+# Install buildout
+
+ws = pkg_resources.working_set
+
+cmd = [sys.executable, '-c',
+       'from setuptools.command.easy_install import main; main()',
+       '-mZqNxd', tmpeggs]
+
+find_links = os.environ.get(
+    'bootstrap-testing-find-links',
+    options.find_links or
+    ('http://downloads.buildout.org/'
+     if options.accept_buildout_test_releases else None)
+    )
+if find_links:
+    cmd.extend(['-f', find_links])
+
+setuptools_path = ws.find(
+    pkg_resources.Requirement.parse('setuptools')).location
+
+requirement = 'zc.buildout'
+version = options.version
+if version is None and not options.accept_buildout_test_releases:
+    # Figure out the most recent final version of zc.buildout.
+    import setuptools.package_index
+    _final_parts = '*final-', '*final'
+
+    def _final_version(parsed_version):
+        for part in parsed_version:
+            if (part[:1] == '*') and (part not in _final_parts):
+                return False
+        return True
+    index = setuptools.package_index.PackageIndex(
+        search_path=[setuptools_path])
+    if find_links:
+        index.add_find_links((find_links,))
+    req = pkg_resources.Requirement.parse(requirement)
+    if index.obtain(req) is not None:
+        best = []
+        bestv = None
+        for dist in index[req.project_name]:
+            distv = dist.parsed_version
+            if _final_version(distv):
+                if bestv is None or distv > bestv:
+                    best = [dist]
+                    bestv = distv
+                elif distv == bestv:
+                    best.append(dist)
+        if best:
+            best.sort()
+            version = best[-1].version
+if version:
+    requirement = '=='.join((requirement, version))
+cmd.append(requirement)
+
+import subprocess
+if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0:
+    raise Exception(
+        "Failed to execute command:\n%s" % repr(cmd)[1:-1])
+
+######################################################################
+# Import and run buildout
+
+ws.add_entry(tmpeggs)
+ws.require(requirement)
+import zc.buildout.buildout
+
+if not [a for a in args if '=' not in a]:
+    args.append('bootstrap')
+
+# if -c was provided, we push it back into args for buildout' main function
+if options.config_file is not None:
+    args[0:0] = ['-c', options.config_file]
+
+zc.buildout.buildout.main(args)
+shutil.rmtree(tmpeggs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/buildout.cfg	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,68 @@
+[buildout]
+eggs-directory = /var/local/env/pyams/eggs
+
+socket-timeout = 3
+show-picked-versions = true
+newest = false
+
+allow-hosts =
+    bitbucket.org
+    *.python.org
+    *.sourceforge.net
+    github.com
+
+#extends = http://download.ztfy.org/webapp/ztfy.webapp.dev.cfg
+versions = versions
+newest = false
+#allow-picked-versions = false
+
+src = src
+develop =
+    .
+    ../pyams_form
+    ../pyams_pagelet
+    ../pyams_skin
+    ../pyams_template
+    ../pyams_utils
+    ../pyams_viewlet
+    ../pyams_zmi
+    ../ext/lingua
+
+parts =
+    package
+    i18n
+    pyflakes
+    test
+
+[package]
+recipe = zc.recipe.egg
+eggs =
+    pyams_zodbbrowser
+    pyramid
+    zope.component
+    zope.interface
+
+[i18n]
+recipe = zc.recipe.egg
+eggs =
+    babel
+    lingua
+
+[pyflakes]
+recipe = zc.recipe.egg
+eggs = pyflakes
+scripts = pyflakes
+entry-points = pyflakes=pyflakes.scripts.pyflakes:main
+initialization = if not sys.argv[1:]: sys.argv[1:] = ["${buildout:src}"]
+
+[pyflakesrun]
+recipe = collective.recipe.cmd
+on_install = true
+cmds = ${buildout:develop}/bin/${pyflakes:scripts}
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = pyams_zodbbrowser [test]
+
+[versions]
+pyams_base = 0.1.0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,68 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+This module contains pyams_zodbbrowser package
+"""
+import os
+from setuptools import setup, find_packages
+
+DOCS = os.path.join(os.path.dirname(__file__),
+                    'docs')
+
+README = os.path.join(DOCS, 'README.txt')
+HISTORY = os.path.join(DOCS, 'HISTORY.txt')
+
+version = '0.1.0'
+long_description = open(README).read() + '\n\n' + open(HISTORY).read()
+
+tests_require = []
+
+setup(name='pyams_zodbbrowser',
+      version=version,
+      description="PyAMS ZODB browser integration",
+      long_description=long_description,
+      classifiers=[
+          "License :: OSI Approved :: Zope Public License",
+          "Development Status :: 4 - Beta",
+          "Programming Language :: Python",
+          "Framework :: Pyramid",
+          "Topic :: Software Development :: Libraries :: Python Modules",
+      ],
+      keywords='Pyramid PyAMS ZODB browser',
+      author='Thierry Florac',
+      author_email='tflorac@ulthar.net',
+      url='http://hg.ztfy.org/pyams/pyams_zodbbrowser',
+      license='ZPL',
+      packages=find_packages('src'),
+      package_dir={'': 'src'},
+      namespace_packages=[],
+      include_package_data=True,
+      package_data={'': ['*.zcml', '*.txt', '*.pt', '*.pot', '*.po', '*.mo', '*.png', '*.gif', '*.jpeg', '*.jpg', '*.css', '*.js']},
+      zip_safe=False,
+      # uncomment this to be able to run tests with setup.py
+      test_suite="pyams_zodbbrowser.tests.test_utilsdocs.test_suite",
+      tests_require=tests_require,
+      extras_require=dict(test=tests_require),
+      install_requires=[
+          'setuptools',
+          # -*- Extra requirements: -*-
+          'pyams_zmi',
+          'pyramid',
+          'zope.component',
+          'zope.interface',
+      ],
+      entry_points={
+          'fanstatic.libraries': [
+              'pyams_zodbbrowser = pyams_zodbbrowser:library'
+          ]
+      })
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/PKG-INFO	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,18 @@
+Metadata-Version: 1.1
+Name: pyams-zodbbrowser
+Version: 0.1.0
+Summary: PyAMS ZODB browser integration
+Home-page: http://hg.ztfy.org/pyams/pyams_zodbbrowser
+Author: Thierry Florac
+Author-email: tflorac@ulthar.net
+License: ZPL
+Description: 
+        
+        
+Keywords: Pyramid PyAMS ZODB browser
+Platform: UNKNOWN
+Classifier: License :: OSI Approved :: Zope Public License
+Classifier: Development Status :: 4 - Beta
+Classifier: Programming Language :: Python
+Classifier: Framework :: Pyramid
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/SOURCES.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,46 @@
+MANIFEST.in
+setup.py
+docs/HISTORY.txt
+docs/README.txt
+src/pyams_zodbbrowser/__init__.py
+src/pyams_zodbbrowser/btreesupport.py
+src/pyams_zodbbrowser/cache.py
+src/pyams_zodbbrowser/configure.zcml
+src/pyams_zodbbrowser/diff.py
+src/pyams_zodbbrowser/history.py
+src/pyams_zodbbrowser/state.py
+src/pyams_zodbbrowser/testing.py
+src/pyams_zodbbrowser/value.py
+src/pyams_zodbbrowser.egg-info/PKG-INFO
+src/pyams_zodbbrowser.egg-info/SOURCES.txt
+src/pyams_zodbbrowser.egg-info/dependency_links.txt
+src/pyams_zodbbrowser.egg-info/entry_points.txt
+src/pyams_zodbbrowser.egg-info/namespace_packages.txt
+src/pyams_zodbbrowser.egg-info/not-zip-safe
+src/pyams_zodbbrowser.egg-info/requires.txt
+src/pyams_zodbbrowser.egg-info/top_level.txt
+src/pyams_zodbbrowser/doctests/README.txt
+src/pyams_zodbbrowser/interfaces/__init__.py
+src/pyams_zodbbrowser/locales/pyams_zodbbrowser.pot
+src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.mo
+src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.po
+src/pyams_zodbbrowser/tests/__init__.py
+src/pyams_zodbbrowser/tests/realdb.py
+src/pyams_zodbbrowser/tests/test_btreesupport.py
+src/pyams_zodbbrowser/tests/test_cache.py
+src/pyams_zodbbrowser/tests/test_diff.py
+src/pyams_zodbbrowser/tests/test_history.py
+src/pyams_zodbbrowser/tests/test_state.py
+src/pyams_zodbbrowser/tests/test_utilsdocs.py
+src/pyams_zodbbrowser/tests/test_utilsdocstrings.py
+src/pyams_zodbbrowser/tests/test_value.py
+src/pyams_zodbbrowser/zmi/__init__.py
+src/pyams_zodbbrowser/zmi/views.py
+src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.css
+src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.min.css
+src/pyams_zodbbrowser/zmi/resources/img/collapse.png
+src/pyams_zodbbrowser/zmi/resources/img/expand.png
+src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.js
+src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.min.js
+src/pyams_zodbbrowser/zmi/templates/zodbhistory.pt
+src/pyams_zodbbrowser/zmi/templates/zodbinfo.pt
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/dependency_links.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/entry_points.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,3 @@
+[fanstatic.libraries]
+pyams_zodbbrowser = pyams_zodbbrowser:library
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/namespace_packages.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/not-zip-safe	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/requires.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,7 @@
+setuptools
+pyams_zmi
+pyramid
+zope.component
+zope.interface
+
+[test]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser.egg-info/top_level.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+pyams_zodbbrowser
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/__init__.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,35 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+from fanstatic import Library
+
+from pyramid.i18n import TranslationStringFactory
+_ = TranslationStringFactory('pyams_zodbbrowser')
+
+
+library = Library('pyams_zodbbrowser', 'zmi/resources')
+
+
+def includeme(config):
+    """Pyramid include"""
+
+    # add translations
+    config.add_translation_dirs('pyams_zodbbrowser:locales')
+
+    # load registry components
+    config.scan()
+
+    if hasattr(config, 'load_zcml'):
+        config.load_zcml('configure.zcml')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/btreesupport.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,238 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+"""
+BTrees are commonly used in the Zope world.  This modules exposes the
+contents of BTrees nicely, abstracting away the implementation details.
+
+In the DB, every BTree can be represented by more than one persistent object,
+every one of those versioned separately.  This is part of what makes BTrees
+efficient.
+
+The format of the picked BTree state is nicely documented in ZODB's source
+code, specifically, BTreeTemplate.c and BucketTemplate.c.
+"""
+from pyams_utils.request import check_request
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_zodbbrowser.interfaces import IStateInterpreter, IObjectHistory
+
+# import packages
+
+# be compatible with Zope 3.4, but prefer the modern package structure
+from pyams_utils.adapter import adapter_config
+from pyams_zodbbrowser.history import ZodbObjectHistory
+from pyams_zodbbrowser.state import GenericState
+from BTrees.OOBTree import OOBTree, OOBucket
+from zope.container.folder import Folder
+from zope.container.btree import BTreeContainer
+from zope.interface import implementer
+
+
+@adapter_config(context=OOBTree, provides=IObjectHistory)
+@implementer(IObjectHistory)
+class OOBTreeHistory(ZodbObjectHistory):
+
+    def _load(self):
+        # find all objects (tree and buckets) that have ever participated in
+        # this OOBTree
+        queue = [self._obj]
+        seen = set(self._oid)
+        history_of = {}
+        while queue:
+            obj = queue.pop(0)
+            history = history_of[obj._p_oid] = ZodbObjectHistory(obj)
+            for d in history:
+                state = history.loadState(d['tid'])
+                if state and len(state) > 1:
+                    bucket = state[1]
+                    if bucket._p_oid not in seen:
+                        queue.append(bucket)
+                        seen.add(bucket._p_oid)
+        # merge the histories of all objects
+        by_tid = {}
+        for h in list(history_of.values()):
+            for d in h:
+                by_tid.setdefault(d['tid'], d)
+        self._history = list(by_tid.values())
+        self._history.sort(key=lambda d: d['tid'], reverse=True)
+        self._index_by_tid()
+
+    def _lastRealChange(self, tid=None):
+        return ZodbObjectHistory(self._obj).lastChange(tid)
+
+    def loadStatePickle(self, tid=None):
+        # lastChange would return the tid that modified self._obj or any
+        # of its subobjects, thanks to the history merging done by _load.
+        # We need the real last change value.
+        # XXX: this is used to show the pickled size of an object.  It
+        # will be misleading for BTrees if we show just the size for the
+        # main BTree object while we're hiding all the individual buckets.
+        return self._connection._storage.loadSerial(self._obj._p_oid,
+                                                    self._lastRealChange(tid))
+
+    def loadState(self, tid=None):
+        # lastChange would return the tid that modified self._obj or any
+        # of its subobjects, thanks to the history merging done by _load.
+        # We need the real last change value.
+        return self._connection.oldstate(self._obj, self._lastRealChange(tid))
+
+    def rollback(self, tid):
+        state = self.loadState(tid)
+        if state != self.loadState():
+            self._obj.__setstate__(state)
+            self._obj._p_changed = True
+
+        while state and len(state) > 1:
+            bucket = state[1]
+            bucket_history = IObjectHistory(bucket)
+            state = bucket_history.loadState(tid)
+            if state != bucket_history.loadState():
+                bucket.__setstate__(state)
+                bucket._p_changed = True
+
+
+@adapter_config(context=(OOBTree, tuple, None), provides=IStateInterpreter)
+@implementer(IStateInterpreter)
+class OOBTreeState(object):
+    """Non-empty OOBTrees have a complicated tuple structure."""
+
+    def __init__(self, type, state, tid):
+        self.btree = OOBTree()
+        self.btree.__setstate__(state)
+        self.state = state
+        # Large btrees have more than one bucket; we have to load old states
+        # to all of them.  See BTreeTemplate.c and BucketTemplate.c for
+        # docs of the pickled state format.
+        while state and len(state) > 1:
+            bucket = state[1]
+            state = IObjectHistory(bucket).loadState(tid)
+            # XXX this is dangerous!
+            bucket.__setstate__(state)
+
+        self._items = list(self.btree.items())
+        self._dict = dict(self.btree)
+
+        # now UNDO to avoid dangerous side effects,
+        # see https://bugs.launchpad.net/zodbbrowser/+bug/487243
+        state = self.state
+        while state and len(state) > 1:
+            bucket = state[1]
+            state = IObjectHistory(bucket).loadState()
+            bucket.__setstate__(state)
+
+    def getError(self):
+        return None
+
+    def getName(self):
+        return None
+
+    def getParent(self):
+        return None
+
+    def listAttributes(self):
+        return None
+
+    def listItems(self):
+        return self._items
+
+    def asDict(self):
+        return self._dict
+
+
+@adapter_config(context=(OOBTree, type(None), None), provides=IStateInterpreter)
+class EmptyOOBTreeState(OOBTreeState):
+    """Empty OOBTrees pickle to None."""
+
+
+@adapter_config(context=(Folder, dict, None), provides=IStateInterpreter)
+class FolderState(GenericState):
+    """Convenient access to a Folder's items"""
+
+    def listItems(self):
+        data = self.state.get('data')
+        if not data:
+            return []
+        # data will be an OOBTree
+        loadedstate = IObjectHistory(data).loadState(self.tid)
+        registry = check_request().registry
+        return registry.getMultiAdapter((data, loadedstate, self.tid),
+                                        IStateInterpreter).listItems()
+
+
+@adapter_config(context=(BTreeContainer, dict, None), provides=IStateInterpreter)
+class BTreeContainerState(GenericState):
+    """Convenient access to a BTreeContainer's items"""
+
+    def listItems(self):
+        # This is not a typo; BTreeContainer really uses
+        # _SampleContainer__data, for BBB
+        data = self.state.get('_SampleContainer__data')
+        if not data:
+            return []
+        # data will be an OOBTree
+        loadedstate = IObjectHistory(data).loadState(self.tid)
+        registry = check_request().registry
+        return registry.getMultiAdapter((data, loadedstate, self.tid),
+                                        IStateInterpreter).listItems()
+
+
+@adapter_config(context=(OOBucket, tuple, None), provides=IStateInterpreter)
+class OOBucketState(GenericState):
+    """A single OOBTree bucket, should you wish to look at the internals
+
+    Here's the state description direct from BTrees/BucketTemplate.c::
+
+     * For a set bucket (self->values is NULL), a one-tuple or two-tuple.  The
+     * first element is a tuple of keys, of length self->len.  The second element
+     * is the next bucket, present if and only if next is non-NULL:
+     *
+     *     (
+     *          (keys[0], keys[1], ..., keys[len-1]),
+     *          <self->next iff non-NULL>
+     *     )
+     *
+     * For a mapping bucket (self->values is not NULL), a one-tuple or two-tuple.
+     * The first element is a tuple interleaving keys and values, of length
+     * 2 * self->len.  The second element is the next bucket, present iff next is
+     * non-NULL:
+     *
+     *     (
+     *          (keys[0], values[0], keys[1], values[1], ...,
+     *                               keys[len-1], values[len-1]),
+     *          <self->next iff non-NULL>
+     *     )
+
+    OOBucket is a mapping bucket; OOSet is a set bucket.
+    """
+
+    def getError(self):
+        return None
+
+    def getName(self):
+        return None
+
+    def getParent(self):
+        return None
+
+    def listAttributes(self):
+        return [('_next', self.state[1] if len(self.state) > 1 else None)]
+
+    def listItems(self):
+        return list(zip(self.state[0][::2], self.state[0][1::2]))
+
+    def asDict(self):
+        return dict(self.listAttributes(), _items=dict(self.listItems()))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/cache.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,59 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import time
+import weakref
+
+# import interfaces
+
+# import packages
+
+
+MINUTES = 60
+HOURS = 60 * MINUTES
+
+STORAGE_TIDS = weakref.WeakKeyDictionary()
+
+
+def expired(cache_dict, cache_for):
+    if 'last_update' not in cache_dict:
+        return True
+    return time.time() > cache_dict['last_update'] + cache_for
+
+
+def getStorageTids(storage, cache_for=5 * MINUTES):
+    cache_dict = STORAGE_TIDS.setdefault(storage, {})
+    if expired(cache_dict, cache_for):
+        if cache_dict.get('tids'):
+            first = cache_dict['tids'][-1]
+            last = cache_dict['tids'][-1]
+            try:
+                first_record = next(storage.iterator())
+            except StopIteration:
+                first_record = None
+            if first_record and first_record.tid == first:
+                # okay, look for new transactions appended at the end
+                new = [t.tid for t in storage.iterator(start=last)]
+                if new and new[0] == last:
+                    del new[0]
+                cache_dict['tids'].extend(new)
+            else:
+                # first record changed, we must've packed the DB
+                cache_dict['tids'] = [t.tid for t in storage.iterator()]
+        else:
+            cache_dict['tids'] = [t.tid for t in storage.iterator()]
+        cache_dict['last_update'] = time.time()
+    return cache_dict['tids']
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/configure.zcml	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,6 @@
+<configure
+	xmlns="http://pylonshq.com/pyramid">
+
+	<include package="pyramid_zcml" />
+
+</configure>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/diff.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,136 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+from html import escape
+
+# import interfaces
+from pyams_zodbbrowser.interfaces import IValueRenderer
+
+# import packages
+
+
+ADDED = 'added'
+REMOVED = 'removed'
+CHANGED = 'changed to'
+
+
+def compareDicts(new, old):
+    """Compare two state dictionaries, return dict."""
+    diffs = {}
+    for key, value in list(new.items()):
+        if key not in old:
+            diffs[key] = (ADDED, value, None)
+        elif old[key] != value:
+            diffs[key] = (CHANGED, value, old[key])
+    for key, value in list(old.items()):
+        if key not in new:
+            diffs[key] = (REMOVED, None, value)
+    return diffs
+
+
+def isascii(s):
+    """See if the string can be safely converted to unicode."""
+    try:
+        s.encode('ascii')
+    except UnicodeError:
+        return False
+    else:
+        return True
+
+
+def compareTuples(new, old):
+    """Compare two tuples.
+
+    Return (common_prefix, middle_of_old, middle_of_new, common_suffix)
+    """
+    first = 0
+    for oldval, newval in zip(old, new):
+        if oldval != newval:
+            break
+        first += 1
+    last = 0
+    for oldval, newval in zip(reversed(old[first:]), reversed(new[first:])):
+        if oldval != newval:
+            break
+        last += 1
+    return (old[:first],
+            old[first:len(old) - last],
+            new[first:len(new) - last],
+            old[len(old) - last:])
+
+
+def compareTuplesHTML(new, old, tid=None, indent=''):
+    """Compare two tuples, return HTML."""
+    html = [indent + '<div class="diff">\n']
+    prefix, removed, added, suffix = compareTuples(new, old)
+    if len(prefix) > 0:
+        html.append(indent + '  <div class="diffitem %s">\n' % 'same')
+        if len(prefix) == 1:
+            html.append(indent + '    first item kept the same\n')
+        else:
+            html.append(indent + '    first %d items kept the same\n' % len(prefix))
+        html.append(indent + '  </div>\n')
+    for oldval in removed:
+        html.append(indent + '  <div class="diffitem %s">\n' % REMOVED)
+        html.append(indent + '    %s %s\n' % (
+            REMOVED, IValueRenderer(oldval).render(tid)))
+        html.append(indent + '  </div>\n')
+    for newval in added:
+        html.append(indent + '  <div class="diffitem %s">\n' % ADDED)
+        html.append(indent + '    %s %s\n' % (
+            ADDED, IValueRenderer(newval).render(tid)))
+        html.append(indent + '  </div>\n')
+    if len(suffix) > 0:
+        html.append(indent + '  <div class="diffitem %s">\n' % 'same')
+        if len(suffix) == 1:
+            html.append(indent + '    last item kept the same\n')
+        else:
+            html.append(indent + '    last %d items kept the same\n' % len(suffix))
+        html.append(indent + '  </div>\n')
+    html.append(indent + '</div>\n')
+    return ''.join(html)
+
+
+def compareDictsHTML(new, old, tid=None, indent=''):
+    """Compare two state dictionaries, return HTML."""
+    html = [indent + '<div class="diff">\n']
+    diff = compareDicts(new, old)
+    for key, (action, newvalue, oldvalue) in sorted(list(diff.items()),
+                                                    key=lambda k_v: (str(type(k_v[0])), k_v[0])):
+        what = action.split()[0]
+        html.append(indent + '  <div class="diffitem %s">\n' % escape(what))
+        if isinstance(key, str) and isascii(key):
+            html.append(indent + '    <strong>%s</strong>: ' % escape(key))
+        else:
+            html.append(indent + '    <strong>%s</strong>: ' % IValueRenderer(key).render(tid))
+        if (action == CHANGED and isinstance(oldvalue, dict) and isinstance(newvalue, dict)):
+            html.append('dictionary changed:\n')
+            html.append(compareDictsHTML(newvalue, oldvalue, tid, indent=indent + '    '))
+        elif (action == CHANGED and isinstance(oldvalue, tuple) and isinstance(newvalue, tuple)):
+            html.append('tuple changed:\n')
+            html.append(compareTuplesHTML(newvalue, oldvalue, tid, indent=indent + '    '))
+        else:
+            html.append(action)
+            html.append(' ')
+            if action == REMOVED:
+                value = oldvalue
+            else:
+                value = newvalue
+            html.append(IValueRenderer(value).render(tid))
+            html.append('\n')
+        html.append(indent + '  </div>\n')
+    html.append(indent + '</div>\n')
+    return ''.join(html)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/doctests/README.txt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,3 @@
+=========================
+pyams_zodbbrowser package
+=========================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/history.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,138 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import inspect
+
+# import interfaces
+from ZODB.interfaces import IConnection
+from pyams_zodbbrowser.interfaces import IObjectHistory, IDatabaseHistory
+
+# import packages
+from persistent import Persistent
+from pyams_utils.adapter import adapter_config
+from pyams_zodbbrowser import cache
+from ZODB.utils import tid_repr
+from zope.interface import implementer
+
+
+@adapter_config(context=Persistent, provides=IObjectHistory)
+@implementer(IObjectHistory)
+class ZodbObjectHistory(object):
+
+    def __init__(self, obj):
+        self._obj = obj
+        self._connection = self._obj._p_jar
+        self._storage = self._connection._storage
+        self._oid = self._obj._p_oid
+        self._history = None
+        self._by_tid = {}
+
+    def __len__(self):
+        if self._history is None:
+            self._load()
+        return len(self._history)
+
+    def _load(self):
+        """Load history of changes made to a Persistent object.
+
+        Returns a list of dictionaries, from latest revision to the oldest.
+        The dicts have various interesting pieces of data, such as:
+
+            tid -- transaction ID (a byte string, usually 8 bytes)
+            time -- transaction timestamp (number of seconds since the Unix epoch)
+            user_name -- name of the user responsible for the change
+            description -- short description (often a URL)
+
+        See the 'history' method of ZODB.interfaces.IStorage.
+        """
+        size = 999999999999 # "all of it"; ought to be sufficient
+        # NB: ClientStorage violates the interface by calling the last
+        # argument 'length' instead of 'size'.  To avoid problems we must
+        # use positional argument syntax here.
+        # NB: FileStorage in ZODB 3.8 has a mandatory second argument 'version'
+        # FileStorage in ZODB 3.9 doesn't accept a 'version' argument at all.
+        # This check is ugly, but I see no other options if I want to support
+        # both ZODB versions :(
+        if 'version' in inspect.getargspec(self._storage.history)[0]:
+            version = None
+            self._history = self._storage.history(self._oid, version, size)
+        else:
+            self._history = self._storage.history(self._oid, size=size)
+        self._index_by_tid()
+
+    def _index_by_tid(self):
+        for record in self._history:
+            self._by_tid[record['tid']] = record
+
+    def __getitem__(self, item):
+        if self._history is None:
+            self._load()
+        return self._history[item]
+
+    def lastChange(self, tid=None):
+        if self._history is None:
+            self._load()
+        if tid in self._by_tid:
+            # optimization
+            return tid
+        # sadly ZODB has no API for get revision at or before tid, so
+        # we have to find the exact tid
+        for record in self._history:
+            # we assume records are ordered by tid, newest to oldest
+            if tid is None or record['tid'] <= tid:
+                return record['tid']
+        raise KeyError('%r did not exist in or before transaction %r' %
+                       (self._obj, tid_repr(tid)))
+
+    def loadStatePickle(self, tid=None):
+        return self._connection._storage.loadSerial(self._obj._p_oid,
+                                                    self.lastChange(tid))
+
+    def loadState(self, tid=None):
+        return self._connection.oldstate(self._obj, self.lastChange(tid))
+
+    def rollback(self, tid):
+        state = self.loadState(tid)
+        if state != self.loadState():
+            self._obj.__setstate__(state)
+            self._obj._p_changed = True
+
+
+@adapter_config(context=IConnection, provides=IDatabaseHistory)
+@implementer(IObjectHistory)
+class ZodbHistory(object):
+
+    def __init__(self, connection):
+        self._connection = connection
+        self._storage = connection._storage
+        self._tids = cache.getStorageTids(self._storage)
+
+    @property
+    def tids(self):
+        return tuple(self._tids)  # readonlify
+
+    def __len__(self):
+        return len(self._tids)
+
+    def __iter__(self):
+        return self._storage.iterator()
+
+    def __getitem__(self, index):
+        if isinstance(index, slice):
+            tids = self._tids[index]
+            if not tids:
+                return []
+            return self._storage.iterator(tids[0], tids[-1])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/interfaces/__init__.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,145 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
+from zope.interface import Interface
+
+
+class IObjectHistory(Interface):
+    """History of persistent object state.
+
+    Adapt a persistent object to IObjectHistory.
+    """
+
+    def __len__():
+        """Return the number of history records."""
+
+    def __getitem__(n):
+        """Return n-th history record.
+
+        Records are ordered by age, from latest (index 0) to oldest.
+
+        Each record is a dictonary with at least the following items:
+
+            tid -- transaction ID (a byte string, usually 8 bytes)
+            time -- transaction timestamp (Unix time_t value)
+            user_name -- name of the user responsible for the change
+            description -- short description (often a URL)
+
+        """
+
+    def lastChange(tid=None):
+        """Return the last transaction at or before tid.
+
+        If tid is not specified, returns the very last transaction that
+        modified this object.
+
+        Will raise KeyError if object did not exist before the given
+        transaction.
+        """
+
+    def loadState(tid=None):
+        """Load and return the object's historical state at or before tid.
+
+        Returns the unpicked state, not an actual persistent object.
+        """
+
+    def loadStatePickle(tid=None):
+        """Load and return the object's historical state at or before tid.
+
+        Returns the picked state as a string.
+        """
+
+    def rollback(tid):
+        """Roll back object state to what it was at or before tid."""
+
+
+class IDatabaseHistory(Interface):
+    """History of the entire database.
+
+    Adapt a connection object to IObjectHistory.
+    """
+
+    def __iter__(n):
+        """Return an iterator over the history record.
+
+        Records are ordered by age, from oldest (index 0) to newest.
+
+        Each record provides ZODB.interfaces.an IStorageTransactionInformation.
+        """
+
+
+class IValueRenderer(Interface):
+    """Renderer of attribute values."""
+
+    def render(tid=None, can_link=True):
+        """Render object value to HTML.
+
+        Hyperlinks to other persistent objects will be limited to versions
+        at or older than the specified transaction id (``tid``).
+        """
+
+
+class IStateInterpreter(Interface):
+    """Interprets persistent object state.
+
+    Usually you adapt a tuple (object, state, tid) to IStateInterpreter to
+    figure out how a certain object type represents its state for pickling.
+    The tid may be None or may be a transaction id, and is supplied in case
+    you need to look at states of other objects to make a full sense of this
+    one.
+    """
+
+    def getError():
+        """Return an error message, if there was an error loading this state."""
+
+    def listAttributes():
+        """Return the attributes of this object as tuples (name, value).
+
+        The order of the attributes returned is irrelevant.
+
+        May return None to indicate that this kind of object cannot
+        store attributes.
+        """
+
+    def listItems():
+        """Return the items of this object as tuples (name, value).
+
+        The order of the attributes returned matters.
+
+        Often these are not stored directly, but extracted from an attribute
+        and presented as items for convenience.
+
+        May return None to indicate that this kind of object is not a
+        container and cannot store items.
+        """
+
+    def getParent():
+        """Return the parent of this object."""
+
+    def getName():
+        """Return the name of this object."""
+
+    def asDict():
+        """Return the state expressed as an attribute dictionary.
+
+        The state should combine the attributes and items somehow, to present
+        a complete picture for the purpose of comparing these dictionaries
+        while looking for changes.
+        """
Binary file src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.mo has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/locales/fr/LC_MESSAGES/pyams_zodbbrowser.po	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,21 @@
+#
+# French translations for PACKAGE package
+# This file is distributed under the same license as the PACKAGE package.
+# Thierry Florac <tflorac@ulthar.net>, 2015.
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-03-02 10:09+0100\n"
+"PO-Revision-Date: 2015-03-02 10:12+0100\n"
+"Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
+"Language-Team: French <traduc@traduc.org>\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: src/pyams_zodbbrowser/zmi/views.py:127
+msgid "ZODB browser"
+msgstr "Navigateur ZODB"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/locales/pyams_zodbbrowser.pot	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,21 @@
+# 
+# SOME DESCRIPTIVE TITLE
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2015.
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-03-02 10:09+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+
+#: ./src/pyams_zodbbrowser/zmi/views.py:127
+msgid "ZODB browser"
+msgstr ""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/state.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,329 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import logging
+
+# import interfaces
+from zope.interface.interfaces import IInterface
+from zope.traversing.interfaces import IContainmentRoot
+
+# import packages
+from persistent.dict import PersistentDict
+from persistent.list import PersistentList
+from persistent.mapping import PersistentMapping
+from pyams_utils.adapter import adapter_config
+from pyams_utils.request import check_request
+from pyams_zodbbrowser.interfaces import IStateInterpreter, IObjectHistory
+from ZODB.utils import u64
+from zope.interface import implementer, Interface
+from zope.interface.interface import InterfaceClass
+
+import zope.interface.declarations
+
+# be compatible with Zope 3.4, but prefer the modern package structure
+from zope.container.sample import SampleContainer
+from zope.container.ordered import OrderedContainer
+from zope.container.contained import ContainedProxy
+
+
+log = logging.getLogger(__name__)
+
+
+real_Provides = zope.interface.declarations.Provides
+
+
+def install_provides_hack():
+    """Monkey-patch zope.interface.Provides with a more lenient version.
+
+    A common result of missing modules in sys.path is that you cannot
+    unpickle objects that have been marked with directlyProvides() to
+    implement interfaces that aren't currently available.  Those interfaces
+    are replaced by persistent broken placeholders, which aren classes,
+    not interfaces, and aren't iterable, causing TypeErrors during unpickling.
+    """
+    zope.interface.declarations.Provides = Provides
+
+
+def flatten_interfaces(args):
+    result = []
+    for a in args:
+        if isinstance(a, (list, tuple)):
+            result.extend(flatten_interfaces(a))
+        elif IInterface.providedBy(a):
+            result.append(a)
+        else:
+            log.warning('  replacing %s with a placeholder', repr(a))
+            result.append(InterfaceClass(a.__name__,
+                            __module__='broken ' + a.__module__))
+    return result
+
+
+def Provides(cls, *interfaces):
+    try:
+        return real_Provides(cls, *interfaces)
+    except TypeError as e:
+        log.warning('Suppressing TypeError while unpickling Provides: %s', e)
+        args = flatten_interfaces(interfaces)
+        return real_Provides(cls, *args)
+
+
+@implementer(IStateInterpreter)
+class ZodbObjectState(object):
+
+    def __init__(self, obj, tid=None, _history=None):
+        self.obj = obj
+        if _history is None:
+            _history = IObjectHistory(self.obj)
+        else:
+            assert _history._obj is self.obj
+        self.history = _history
+        self.tid = None
+        self.requestedTid = tid
+        self.loadError = None
+        self.pickledState = ''
+        self._load()
+
+    def _load(self):
+        self.tid = self.history.lastChange(self.requestedTid)
+        try:
+            self.pickledState = self.history.loadStatePickle(self.tid)
+            loadedState = self.history.loadState(self.tid)
+        except Exception as e:
+            self.loadError = "%s: %s" % (e.__class__.__name__, e)
+            self.state = LoadErrorState(self.loadError, self.requestedTid)
+        else:
+            request = check_request()
+            self.state = request.registry.getMultiAdapter((self.obj, loadedState, self.requestedTid),
+                                                          IStateInterpreter)
+
+    def getError(self):
+        return self.loadError
+
+    def listAttributes(self):
+        return self.state.listAttributes()
+
+    def listItems(self):
+        return self.state.listItems()
+
+    def getParent(self):
+        return self.state.getParent()
+
+    def getName(self):
+        name = self.state.getName()
+        if name is None:
+            # __name__ is not in the pickled state, but it may be defined
+            # via other means (e.g. class attributes, custom __getattr__ etc.)
+            try:
+                name = getattr(self.obj, '__name__', None)
+            except Exception:
+                # Ouch.  Oh well, we can't determine the name.
+                pass
+        return name
+
+    def asDict(self):
+        return self.state.asDict()
+
+    # These are not part of IStateInterpreter
+
+    def getObjectId(self):
+        return u64(self.obj._p_oid)
+
+    def isRoot(self):
+        return IContainmentRoot.providedBy(self.obj)
+
+    def getParentState(self):
+        parent = self.getParent()
+        if parent is None:
+            return None
+        else:
+            return ZodbObjectState(parent, self.requestedTid)
+
+
+@implementer(IStateInterpreter)
+class LoadErrorState(object):
+    """Placeholder for when an object's state could not be loaded"""
+
+    def __init__(self, error, tid):
+        self.error = error
+        self.tid = tid
+
+    def getError(self):
+        return self.error
+
+    def getName(self):
+        return None
+
+    def getParent(self):
+        return None
+
+    def listAttributes(self):
+        return []
+
+    def listItems(self):
+        return None
+
+    def asDict(self):
+        return {}
+
+
+@adapter_config(context=(Interface, dict, None), provides=IStateInterpreter)
+@implementer(IStateInterpreter)
+class GenericState(object):
+    """Most persistent objects represent their state as a dict."""
+
+    def __init__(self, type, state, tid):
+        self.state = state
+        self.tid = tid
+
+    def getError(self):
+        return None
+
+    def getName(self):
+        return self.state.get('__name__')
+
+    def getParent(self):
+        return self.state.get('__parent__')
+
+    def listAttributes(self):
+        return list(self.state.items())
+
+    def listItems(self):
+        return None
+
+    def asDict(self):
+        return self.state
+
+
+@adapter_config(context=(PersistentMapping, dict, None), provides=IStateInterpreter)
+class PersistentMappingState(GenericState):
+    """Convenient access to a persistent mapping's items."""
+
+    def listItems(self):
+        return sorted(self.state.get('data', {}).items())
+
+
+if PersistentMapping is PersistentDict:
+    # ZODB 3.9 deprecated PersistentDict and made it an alias for
+    # PersistentMapping.  I don't know a clean way to conditionally disable the
+    # <adapter> directive in ZCML to avoid conflicting configuration actions,
+    # therefore I'll register a decoy adapter registered for a decoy class.
+    # This adapter will never get used.
+
+    class DecoyPersistentDict(PersistentMapping):
+        """Decoy to avoid ZCML errors while supporting both ZODB 3.8 and 3.9."""
+
+    @adapter_config(context=(DecoyPersistentDict, dict, None), provides=IStateInterpreter)
+    class PersistentDictState(PersistentMappingState):
+        """Decoy to avoid ZCML errors while supporting both ZODB 3.8 and 3.9."""
+
+else:
+
+    @adapter_config(context=(PersistentDict, dict, None), provides=IStateInterpreter)
+    class PersistentDictState(PersistentMappingState):
+        """Convenient access to a persistent dict's items."""
+
+
+@adapter_config(context=(SampleContainer, dict, None), provides=IStateInterpreter)
+class SampleContainerState(GenericState):
+    """Convenient access to a SampleContainer's items"""
+
+    def listItems(self):
+        data = self.state.get('_SampleContainer__data')
+        if not data:
+            return []
+        # data will be something persistent, maybe a PersistentDict, maybe a
+        # OOBTree -- SampleContainer itself uses a plain Python dict, but
+        # subclasses are supposed to overwrite the _newContainerData() method
+        # and use something persistent.
+        loadedstate = IObjectHistory(data).loadState(self.tid)
+        request = check_request()
+        return request.registry.getMultiAdapter((data, loadedstate, self.tid),
+                                                IStateInterpreter).listItems()
+
+
+@adapter_config(context=(OrderedContainer, dict, None), provides=IStateInterpreter)
+class OrderedContainerState(GenericState):
+    """Convenient access to an OrderedContainer's items"""
+
+    def listItems(self):
+        # Now this is tricky: we want to construct a small object graph using
+        # old state pickles without ever calling __setstate__ on a real
+        # Persistent object, as _that_ would poison ZODB in-memory caches
+        # in a nasty way (LP #487243).
+        container = OrderedContainer()
+        container.__setstate__(self.state)
+        if isinstance(container._data, PersistentDict):
+            old_data_state = IObjectHistory(container._data).loadState(self.tid)
+            container._data = PersistentDict()
+            container._data.__setstate__(old_data_state)
+        if isinstance(container._order, PersistentList):
+            old_order_state = IObjectHistory(container._order).loadState(self.tid)
+            container._order = PersistentList()
+            container._order.__setstate__(old_order_state)
+        return list(container.items())
+
+
+@adapter_config(context=(ContainedProxy, tuple, None), provides=IStateInterpreter)
+class ContainedProxyState(GenericState):
+
+    def __init__(self, proxy, state, tid):
+        GenericState.__init__(self, proxy, state, tid)
+        self.proxy = proxy
+
+    def getName(self):
+        return self.state[1]
+
+    def getParent(self):
+        return self.state[0]
+
+    def listAttributes(self):
+        return [('__name__', self.getName()),
+                ('__parent__', self.getParent()),
+                ('proxied_object', self.proxy.__getnewargs__()[0])]
+
+    def listItems(self):
+        return []
+
+    def asDict(self):
+        return dict(self.listAttributes())
+
+
+@adapter_config(context=(Interface, Interface, None), provides=IStateInterpreter)
+@implementer(IStateInterpreter)
+class FallbackState(object):
+    """Fallback when we've got no idea how to interpret the state"""
+
+    def __init__(self, type, state, tid):
+        self.state = state
+
+    def getError(self):
+        return None
+
+    def getName(self):
+        return None
+
+    def getParent(self):
+        return None
+
+    def listAttributes(self):
+        return [('pickled state', self.state)]
+
+    def listItems(self):
+        return None
+
+    def asDict(self):
+        return dict(self.listAttributes())
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/tests/__init__.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/tests/test_utilsdocs.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,59 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_zodbbrowser doctest
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.dirname(__file__)
+
+def doc_suite(test_dir, setUp=None, tearDown=None, globs=None):
+    """Returns a test suite, based on doctests found in /doctest."""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    doctest_dir = os.path.join(package_dir, 'doctests')
+
+    # filtering files on extension
+    docs = [os.path.join(doctest_dir, doc) for doc in
+            os.listdir(doctest_dir) if doc.endswith('.txt')]
+
+    for test in docs:
+        suite.append(doctest.DocFileSuite(test, optionflags=flags,
+                                          globs=globs, setUp=setUp,
+                                          tearDown=tearDown,
+                                          module_relative=False))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/tests/test_utilsdocstrings.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,62 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_zodbbrowser doc strings
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.abspath(os.path.dirname(__file__))
+
+def doc_suite(test_dir, globs=None):
+    """Returns a test suite, based on doc tests strings found in /*.py"""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    # filtering files on extension
+    docs = [doc for doc in
+            os.listdir(package_dir) if doc.endswith('.py')]
+    docs = [doc for doc in docs if not doc.startswith('__')]
+
+    for test in docs:
+        fd = open(os.path.join(package_dir, test))
+        content = fd.read()
+        fd.close()
+        if '>>> ' not in content:
+            continue
+        test = test.replace('.py', '')
+        location = 'pyams_zodbbrowser.%s' % test
+        suite.append(doctest.DocTestSuite(location, optionflags=flags,
+                                          globs=globs))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/value.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,304 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import logging
+import itertools
+import collections
+import re
+from html import escape
+
+# import interfaces
+from pyams_zodbbrowser.interfaces import IValueRenderer, IObjectHistory
+
+# import packages
+from persistent import Persistent
+from persistent.dict import PersistentDict
+from persistent.list import PersistentList
+from persistent.mapping import PersistentMapping
+from pyams_utils.adapter import adapter_config
+from ZODB.utils import u64, oid_repr
+from zope.interface import implementer, Interface
+from zope.interface.declarations import ProvidesClass
+
+
+log = logging.getLogger(__name__)
+
+
+MAX_CACHE_SIZE = 1000
+TRUNCATIONS = {}
+TRUNCATIONS_IN_ORDER = collections.deque()
+next_id = itertools.count(1).__next__
+
+
+def resetTruncations():  # for tests only!
+    global next_id
+    next_id = itertools.count(1).__next__
+    TRUNCATIONS.clear()
+    TRUNCATIONS_IN_ORDER.clear()
+
+
+def pruneTruncations():
+    while len(TRUNCATIONS_IN_ORDER) > MAX_CACHE_SIZE:
+        del TRUNCATIONS[TRUNCATIONS_IN_ORDER.popleft()]
+
+
+def truncate(text):
+    id = 'tr%d' % next_id()
+    TRUNCATIONS[id] = text
+    TRUNCATIONS_IN_ORDER.append(id)
+    return id
+
+
+@adapter_config(context=Interface, provides=IValueRenderer)
+@implementer(IValueRenderer)
+class GenericValue(object):
+    """Default value renderer.
+
+    Uses the object's __repr__, truncating if too long.
+    """
+
+    def __init__(self, context):
+        self.context = context
+
+    def _repr(self):
+        # hook for subclasses
+        if getattr(self.context.__class__, '__repr__', None) is object.__repr__:
+            # Special-case objects with the default __repr__ (LP#1087138)
+            if isinstance(self.context, Persistent):
+                return '<%s.%s with oid %s>' % (
+                    self.context.__class__.__module__,
+                    self.context.__class__.__name__,
+                    oid_repr(self.context._p_oid))
+        try:
+            return repr(self.context)
+        except Exception:
+            try:
+                return '<unrepresentable %s>' % self.context.__class__.__name__
+            except Exception:
+                return '<unrepresentable>'
+
+    def render(self, tid=None, can_link=True, limit=200):
+        text = self._repr()
+        if len(text) > limit:
+            id = truncate(text[limit:])
+            text = '%s<span id="%s" class="truncated">...</span>' % (
+                escape(text[:limit]), id)
+        else:
+            text = escape(text)
+        if not isinstance(self.context, str):
+            try:
+                n = len(self.context)
+            except Exception:
+                pass
+            else:
+                if n == 1: # this is a crime against i18n, but oh well
+                    text += ' (%d item)' % n
+                else:
+                    text += ' (%d items)' % n
+        return text
+
+
+def join_with_commas(html, open, close):
+    """Helper to join multiple html snippets into a struct."""
+    prefix = open + '<span class="struct">'
+    suffix = '</span>'
+    for n, item in enumerate(html):
+        if n == len(html) - 1:
+            trailer = close
+        else:
+            trailer = ','
+        if item.endswith(suffix):
+            item = item[:-len(suffix)] + trailer + suffix
+        else:
+            item += trailer
+        html[n] = item
+    return prefix + '<br />'.join(html) + suffix
+
+
+@adapter_config(context=str, provides=IValueRenderer)
+class StringValue(GenericValue):
+    """String renderer."""
+
+    def __init__(self, context):
+        self.context = context
+
+    def render(self, tid=None, can_link=True, limit=200, threshold=4):
+        if self.context.count('\n') <= threshold:
+            return GenericValue.render(self, tid, can_link=can_link,
+                                       limit=limit)
+        else:
+            if isinstance(self.context, str):
+                prefix = 'u'
+                context = self.context
+            else:
+                prefix = ''
+                context = self.context.decode('latin-1').encode('ascii',
+                                                            'backslashreplace')
+            lines = [re.sub(r'^[ \t]+',
+                            lambda m: '&nbsp;' * len(m.group(0).expandtabs()),
+                            escape(line))
+                     for line in context.splitlines()]
+            nl = '<br />' # hm, maybe '\\n<br />'?
+            if sum(map(len, lines)) > limit:
+                head = nl.join(lines[:5])
+                tail = nl.join(lines[5:])
+                id = truncate(tail)
+                return (prefix + "'<span class=\"struct\">" + head + nl
+                        + '<span id="%s" class="truncated">...</span>' % id
+                        + "'</span>")
+            else:
+                return (prefix + "'<span class=\"struct\">" + nl.join(lines)
+                        + "'</span>")
+
+
+@adapter_config(context=tuple, provides=IValueRenderer)
+@implementer(IValueRenderer)
+class TupleValue(object):
+    """Tuple renderer."""
+
+    def __init__(self, context):
+        self.context = context
+
+    def render(self, tid=None, can_link=True, threshold=100):
+        html = []
+        for item in self.context:
+            html.append(IValueRenderer(item).render(tid, can_link))
+        if len(html) == 1:
+            html.append('')  # (item) -> (item, )
+        result = '(%s)' % ', '.join(html)
+        if len(result) > threshold or '<span class="struct">' in result:
+            if len(html) == 2 and html[1] == '':
+                return join_with_commas(html[:1], '(', ', )')
+            else:
+                return join_with_commas(html, '(', ')')
+        return result
+
+
+@adapter_config(context=list, provides=IValueRenderer)
+@implementer(IValueRenderer)
+class ListValue(object):
+    """List renderer."""
+
+    def __init__(self, context):
+        self.context = context
+
+    def render(self, tid=None, can_link=True, threshold=100):
+        html = []
+        for item in self.context:
+            html.append(IValueRenderer(item).render(tid, can_link))
+        result = '[%s]' % ', '.join(html)
+        if len(result) > threshold or '<span class="struct">' in result:
+            return join_with_commas(html, '[', ']')
+        return result
+
+
+@adapter_config(context=dict, provides=IValueRenderer)
+@implementer(IValueRenderer)
+class DictValue(object):
+    """Dict renderer."""
+
+    def __init__(self, context):
+        self.context = context
+
+    def render(self, tid=None, can_link=True, threshold=100):
+        html = []
+        for key, value in sorted(self.context.items()):
+            html.append(IValueRenderer(key).render(tid, can_link) + ': ' +
+                        IValueRenderer(value).render(tid, can_link))
+        if (sum(map(len, html)) < threshold and
+                '<span class="struct">' not in ''.join(html)):
+            return '{%s}' % ', '.join(html)
+        else:
+            return join_with_commas(html, '{', '}')
+
+
+@adapter_config(context=Persistent, provides=IValueRenderer)
+@implementer(IValueRenderer)
+class PersistentValue(object):
+    """Persistent object renderer.
+
+    Uses __repr__ and makes it a hyperlink to the actual object.
+    """
+
+    view_name = '#zodbbrowser'
+    delegate_to = GenericValue
+
+    def __init__(self, context):
+        self.context = context
+
+    def render(self, tid=None, can_link=True):
+        obj = self.context
+        url = '%s?oid=0x%x' % (self.view_name, u64(self.context._p_oid))
+        if tid is not None:
+            url += "&tid=%d" % u64(tid)
+            try:
+                oldstate = IObjectHistory(self.context).loadState(tid)
+                clone = self.context.__class__.__new__(self.context.__class__)
+                clone.__setstate__(oldstate)
+                clone._p_oid = self.context._p_oid
+                obj = clone
+            except Exception:
+                log.debug('Could not load old state for %s 0x%x',
+                          self.context.__class__, u64(self.context._p_oid))
+        value = self.delegate_to(obj).render(tid, can_link=False)
+        if can_link:
+            return '<a class="objlink" href="%s">%s</a>' % (escape(url), value)
+        else:
+            return value
+
+
+@adapter_config(context=PersistentMapping, provides=IValueRenderer)
+class PersistentMappingValue(PersistentValue):
+    delegate_to = DictValue
+
+
+@adapter_config(context=PersistentList, provides=IValueRenderer)
+class PersistentListValue(PersistentValue):
+    delegate_to = ListValue
+
+
+if PersistentMapping is PersistentDict:
+    # ZODB 3.9 deprecated PersistentDict and made it an alias for
+    # PersistentMapping.  I don't know a clean way to conditionally disable the
+    # <adapter> directive in ZCML to avoid conflicting configuration actions,
+    # therefore I'll register a decoy adapter registered for a decoy class.
+    # This adapter will never get used.
+
+    class DecoyPersistentDict(PersistentMapping):
+        """Decoy to avoid ZCML errors while supporting both ZODB 3.8 and 3.9."""
+
+    @adapter_config(context=DecoyPersistentDict, provides=IValueRenderer)
+    class PersistentDictValue(PersistentValue):
+        """Decoy to avoid ZCML errors while supporting both ZODB 3.8 and 3.9."""
+        delegate_to = DictValue
+
+else:
+    @adapter_config(context=PersistentDict, provides=IValueRenderer)
+    class PersistentDictValue(PersistentValue):
+        delegate_to = DictValue
+
+
+@adapter_config(context=ProvidesClass, provides=IValueRenderer)
+class ProvidesValue(GenericValue):
+    """zope.interface.Provides object renderer.
+
+    The __repr__ of zope.interface.Provides is decidedly unhelpful.
+    """
+
+    def _repr(self):
+        return '<Provides: %s>' % ', '.join(i.__identifier__
+                                            for i in self.context._Provides__args[1:])
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/__init__.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.css	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,258 @@
+div.zodbbrowser {
+  color: black;
+  background-color: white;
+  border: 1px solid #00cc33;
+  min-width: 600px;
+  margin-top: 5px;
+  font-size: 12px;
+  font-weight: normal;
+}
+
+div.zodbbrowser > div {
+  padding: 5px;
+}
+
+div.heading {
+  background-color: #aaffaa;
+  border-bottom: 1px solid #00cc33;
+  padding: 5px;
+}
+
+div.heading h1 {
+  font-size: 16px;
+  color: #0033cc;
+  margin: 0;
+  margin-right: 4px;
+}
+div.heading h1 a:link,
+div.heading h1 a:visited {
+  color: #0033cc;
+  text-decoration: none;
+}
+div.heading h1 a:hover,
+div.heading h1 a:active {
+  color: white;
+}
+
+#path {
+  display: block;
+  border: 1px solid #afa;
+  padding: 1px 2px;
+}
+
+#path:hover {
+  border: 1px solid #00cc33;
+  cursor: default;
+}
+
+input#gotoInput {
+  display: block;
+  width: 100%;
+  border: 1px solid #00cc33;
+  background: white;
+  color: black;
+  font-size: 16px;
+  margin: 0;
+  padding: 1px;
+  font-family: Tahoma, Verdana, sans-serif;
+  font-weight: bold;
+}
+
+#pathError {
+  display: block;
+  font-size: 14px;
+  color: black;
+  font-weight: normal;
+  margin: 6px;
+}
+
+div.zodbbrowser .pickleSize {
+  float: right;
+}
+
+div.zodbbrowser .pickleSize,
+div.zodbbrowser h2 {
+  font-size: 14px;
+  color: black;
+  margin: 2px;
+  font-weight: normal;
+}
+
+div.zodbbrowser h3 {
+  font-size: 14px;
+  color: orange;
+  margin: 2px;
+}
+
+h3.expander {
+  cursor: default;
+  padding: 1px;
+}
+
+h3.expander:hover {
+  background: #f8fff8;
+}
+
+h5.expander {
+  cursor: default;
+  padding: 1px;
+}
+
+h5.expander:hover {
+  background: #f8fff8;
+}
+
+
+div.transaction {
+  margin: 0;
+  padding-bottom: 1em;
+}
+
+h4.transaction {
+  margin: 0;
+  font-size: 14px;
+  font-weight: bold;
+  color: black;
+}
+
+h4.transaction.paging {
+  text-align: right;
+}
+
+h4.transaction a.title:link,
+h4.transaction a.title:visited {
+  display: block;
+  color: black;
+  text-decoration: none;
+  padding: 2px;
+}
+h4.transaction a.title:hover,
+h4.transaction a.title:active {
+  color: #760;
+  background: #ffa;
+}
+
+h4.transaction a.subtitle {
+  float: right;
+  padding: 2px;
+  color: #003344;
+  font-size: 12px;
+  font-weight: normal;
+}
+
+div.transaction div.toolbox {
+  float: right;
+  padding: 4px;
+}
+
+div.transaction div.toolbox {
+  display: none;
+}
+
+div.transaction:hover div.toolbox {
+  display: block;
+}
+
+div.zodbbrowser .truncated {
+  background: #f44;
+  color: white;
+  margin: 2px;
+  padding: 0 1px;
+  border-radius: 2px;
+  -moz-border-radius: 2px;
+  cursor: pointer;
+}
+
+span.struct {
+  display: inline-block;
+  vertical-align: text-top;
+}
+
+a.objlink {
+  display: inline-block;
+  vertical-align: text-top;
+}
+
+a.objlink:hover,
+a.objlink:active {
+  background: #aaf;
+  color: white;
+  text-decoration: none;
+}
+
+.tid-info {
+  color: #141;
+  margin-left: 2em;
+  padding: 1px;
+  background: #ffa;
+  border-bottom: 1px solid #fe0;
+  border-top: 1px solid #fe0;
+  border-left: 1px solid #fe0;
+  border-right: 1px solid #fe0;
+}
+
+div.error,
+div.diff {
+  padding: 4px;
+  margin-left: 2em;
+  margin-bottom: 0;
+}
+
+div.diff ol {
+  padding-top: 0;
+  padding-bottom: 0;
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.removed {
+  text-decoration: line-through;
+  color: #400;
+}
+
+.added {
+  color: #040;
+}
+
+div.filtered {
+  margin-left: 2em;
+  padding-left: 4px;
+  font-size: 12px;
+  color: #444;
+}
+
+div.buttons {
+  padding: 4px;
+}
+a.jsbutton:link,
+a.jsbutton:visited {
+  color: green;
+  padding: 1px 4px;
+}
+
+div.history {
+}
+
+div.current {
+  background: #ffa;
+  border-bottom: 1px solid #fe0;
+  border-top: 1px solid #fe0;
+}
+div.latest {
+  background: #ffa;
+  border-top: 1px solid #fe0;
+  margin-bottom: 0;
+}
+div.focus {
+  background: #fca;
+  border-bottom: 1px solid #f80;
+  border-top: 1px solid #f80;
+}
+div#confirmation {
+  margin-left: 2em;
+  margin-top: 1em;
+  font-weight: bold;
+}
+div#confirmation div.message {
+  margin-bottom: 1ex;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/resources/css/zodbbrowser.min.css	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+div.zodbbrowser{color:black;background-color:white;border:1px solid #0c3;min-width:600px;margin-top:5px;font-size:12px;font-weight:normal}div.zodbbrowser>div{padding:5px}div.heading{background-color:#afa;border-bottom:1px solid #0c3;padding:5px}div.heading h1{font-size:16px;color:#03c;margin:0;margin-right:4px}div.heading h1 a:link,div.heading h1 a:visited{color:#03c;text-decoration:none}div.heading h1 a:hover,div.heading h1 a:active{color:white}#path{display:block;border:1px solid #afa;padding:1px 2px}#path:hover{border:1px solid #0c3;cursor:default}input#gotoInput{display:block;width:100%;border:1px solid #0c3;background:white;color:black;font-size:16px;margin:0;padding:1px;font-family:Tahoma,Verdana,sans-serif;font-weight:bold}#pathError{display:block;font-size:14px;color:black;font-weight:normal;margin:6px}div.zodbbrowser .pickleSize{float:right}div.zodbbrowser .pickleSize,div.zodbbrowser h2{font-size:14px;color:black;margin:2px;font-weight:normal}div.zodbbrowser h3{font-size:14px;color:orange;margin:2px}h3.expander{cursor:default;padding:1px}h3.expander:hover{background:#f8fff8}h5.expander{cursor:default;padding:1px}h5.expander:hover{background:#f8fff8}div.transaction{margin:0;padding-bottom:1em}h4.transaction{margin:0;font-size:14px;font-weight:bold;color:black}h4.transaction.paging{text-align:right}h4.transaction a.title:link,h4.transaction a.title:visited{display:block;color:black;text-decoration:none;padding:2px}h4.transaction a.title:hover,h4.transaction a.title:active{color:#760;background:#ffa}h4.transaction a.subtitle{float:right;padding:2px;color:#034;font-size:12px;font-weight:normal}div.transaction div.toolbox{float:right;padding:4px}div.transaction div.toolbox{display:none}div.transaction:hover div.toolbox{display:block}div.zodbbrowser .truncated{background:#f44;color:white;margin:2px;padding:0 1px;border-radius:2px;-moz-border-radius:2px;cursor:pointer}span.struct{display:inline-block;vertical-align:text-top}a.objlink{display:inline-block;vertical-align:text-top}a.objlink:hover,a.objlink:active{background:#aaf;color:white;text-decoration:none}.tid-info{color:#141;margin-left:2em;padding:1px;background:#ffa;border-bottom:1px solid #fe0;border-top:1px solid #fe0;border-left:1px solid #fe0;border-right:1px solid #fe0}div.error,div.diff{padding:4px;margin-left:2em;margin-bottom:0}div.diff ol{padding-top:0;padding-bottom:0;margin-top:0;margin-bottom:0}.removed{text-decoration:line-through;color:#400}.added{color:#040}div.filtered{margin-left:2em;padding-left:4px;font-size:12px;color:#444}div.buttons{padding:4px}a.jsbutton:link,a.jsbutton:visited{color:green;padding:1px 4px}div.current{background:#ffa;border-bottom:1px solid #fe0;border-top:1px solid #fe0}div.latest{background:#ffa;border-top:1px solid #fe0;margin-bottom:0}div.focus{background:#fca;border-bottom:1px solid #f80;border-top:1px solid #f80}div#confirmation{margin-left:2em;margin-top:1em;font-weight:bold}div#confirmation div.message{margin-bottom:1ex}
\ No newline at end of file
Binary file src/pyams_zodbbrowser/zmi/resources/img/collapse.png has changed
Binary file src/pyams_zodbbrowser/zmi/resources/img/expand.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.js	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,221 @@
+(function($) {
+
+	window.PyAMS_zodbbrowser = {
+
+		filterAll: function () {
+			$('.filter').attr('checked', true);
+			PyAMS_zodbbrowser.filterHistory(true);
+		},
+
+		filterNone: function () {
+			$('.filter').attr('checked', false);
+			PyAMS_zodbbrowser.filterHistory();
+		},
+
+		filterHistory: function (showAll) {
+			var transactions = $('div.transaction');
+			var filters = $('.filter');
+			var filterMap = Array();
+			var MAPPING_PREFIX = "map";
+
+			for (var i = 0; i < filters.length; i++) {
+				var filter = filters[i];
+				if (filter.checked) {
+					filterMap[MAPPING_PREFIX + filter.name] = filter.checked;
+				}
+			}
+
+			for (i = 0; i < transactions.length; i++) {
+				var transaction = transactions[i];
+				var diffs = $(transaction).children('div.diff').children('div.diffitem');
+				var n_hidden = 0;
+				for (var j = 0; j < diffs.length; j++) {
+					var id = $($(diffs[j]).children()[0]).text();
+					if (MAPPING_PREFIX + id in filterMap || showAll) {
+						$(diffs[j]).show();
+					} else {
+						$(diffs[j]).hide();
+						n_hidden += 1;
+					}
+				}
+				var hidden_text = null;
+				if (n_hidden == 1) {
+					hidden_text = '1 item hidden';
+				} else if (n_hidden) {
+					hidden_text = n_hidden + ' items hidden';
+				}
+				$(transaction).children('.filtered').remove();
+				if (hidden_text) {
+					$(transaction).append('<div class="filtered">' + hidden_text + '</div>');
+				}
+			}
+		},
+
+		collapseOrExpand: function () {
+			// this is <div class="extender">
+			// the following element is <div class="collapsible">
+			var content = $(this).next();
+			var icon = $(this).children('img');
+			if (content.is(':hidden')) {
+				$(icon).attr('src', $('#collapseImg').attr('src'));
+				content.slideDown();
+			} else {
+				$(icon).attr('src', $('#expandImg').attr('src'));
+				content.slideUp();
+			}
+		},
+
+		hideItemsIfTooMany: function () {
+			$('.items').each(function () {
+				var expander = $(this).children('.expander')[0];
+				var content = $(this).children('.collapsible')[0];
+				// items are formatted using <br /> so the heuristic is very
+				// approximate.
+				if (content.childNodes.length > 100 && !$(content).is(':hidden')) {
+					var icon = $(expander).children('img');
+					$(icon).attr('src', $('#expandImg').attr('src'));
+					$(content).hide();
+				}
+			});
+		},
+
+		showGoTo: function () {
+			$('#path').hide();
+			$('#goto').show();
+			$('#gotoInput').focus();
+		},
+
+		hideGoTo: function () {
+			$('#goto').hide();
+			$('#path').show();
+			// Don't hide #pathError right away, this blur event might have been the
+			// result of the user clicking on a link inside the #pathError text and
+			// hiding it will prevent that link from getting activated.
+			setTimeout(function () {
+				$('#pathError').slideUp();
+			}, 50);
+		},
+
+		ajaxErrorHandler: function (XMLHttpRequest, textStatus, errorThrown) {
+			errorMessage = "";
+			if (textStatus == "parsererror") {
+				errorMessage = "Server returned malformed data";
+			} else if (textStatus == "error") {
+				errorMessage = "Unknown error (maybe server is offline?)";
+			} else if (textStatus == "timeout") {
+				errorMessage = "Server timeout";
+			} else if (textStatus == "notmodified") {
+				errorMessage = "Server error (says resource not modified)";
+			} else {
+				errorMessage = "Unknown error";
+			}
+
+			errorMessage = '<span class="error"> ' + errorMessage + '</strong>';
+			$('#pathError').html(errorMessage);
+		},
+
+		ajaxSuccessHandler: function (data, status) {
+			if (data.url) {
+				window.location = data.url;
+				$('#pathError').text("Found.").slideDown().slideUp();
+			} else if (data.error) {
+				$('#pathError').text(data.error).show();
+				if (data.partial_url) {
+					$('#pathError').append(', would you like to ' +
+										   '<a href="' + data.partial_url + '">' +
+										   'go to ' + data.partial_path +
+										   '</a>' +
+										   ' instead?');
+				}
+			} else {
+				$('#pathError').text(status).show();
+			}
+		},
+
+		activateGoTo: function () {
+			var path = $('#gotoInput').val();
+			var api_url = 'zodbbrowser_path_to_oid';
+			$('#pathError').text("Loading...").slideDown();
+			$.ajax({
+				url: api_url,
+				dataType: 'json',
+				data: "path=" + path,
+				timeout: 7000,
+				success: PyAMS_zodbbrowser.ajaxSuccessHandler,
+				error: PyAMS_zodbbrowser.ajaxErrorHandler
+			});
+		},
+
+		cancelRollback: function (e) {
+			$('#confirmation').remove();
+			$('div.transaction').removeClass('focus');
+			$('input.rollbackbtn').show();
+		},
+
+		pressRollback: function (e) {
+			e.preventDefault();
+			PyAMS_zodbbrowser.cancelRollback();
+			$(e.target).hide();
+			var transaction_div = $(e.target).closest('div.transaction');
+			transaction_div.addClass('focus');
+			$('<div id="confirmation">' +
+			  '<form action="" method="post">' +
+			  '<div class="message">' +
+			  'This is a dangerous operation that may break data integrity.' +
+			  ' Are you really sure you want to do this?' +
+			  '</div>' +
+			  '<input type="BUTTON" value="Really revert to this state" onclick="PyAMS_zodbbrowser.doRollback()">' +
+			  '<input type="BUTTON" value="Cancel" onclick="PyAMS_zodbbrowser.cancelRollback()">' +
+			  '</form>' +
+			  '</div>').appendTo(transaction_div);
+		},
+
+		doRollback: function () {
+			var transaction_div = $('#confirmation').closest('div.transaction');
+			var rollback_form = transaction_div.find('form.rollback');
+			rollback_form.find('input[name="confirmed"]').val('1');
+			rollback_form.submit();
+		},
+
+		init: function(element) {
+			$('.expander', element).click(PyAMS_zodbbrowser.collapseOrExpand);
+			PyAMS_zodbbrowser.hideItemsIfTooMany();
+			$('#path a', element).click(function (event) {
+				event.stopPropagation();
+			});
+			$('#path', element).click(PyAMS_zodbbrowser.showGoTo);
+			$('#gotoInput', element).blur(PyAMS_zodbbrowser.hideGoTo);
+			$('#gotoInput', element).keypress(function (event) {
+				if (event.which == 13) { // enter
+					PyAMS_zodbbrowser.activateGoTo();
+				}
+			});
+			$('#gotoInput', element).keydown(function (event) {
+				if (event.keyCode == 27) { // escape
+					PyAMS_zodbbrowser.hideGoTo();
+				}
+			});
+			$(document).keypress(function (event) {
+				if (event.which == 103) { // lowercase g
+					if ($('#goto', element).is(':hidden')) {
+						PyAMS_zodbbrowser.showGoTo();
+						event.preventDefault();
+					}
+				}
+			});
+			$('input.rollbackbtn', element).click(PyAMS_zodbbrowser.pressRollback);
+			$('span.truncated', element).click(function (event) {
+				event.preventDefault();
+				var placeholder = $(this);
+				var id = placeholder.attr('id');
+				$.ajax({
+					url: 'zodbbrowser_truncated', data: 'id=' + id,
+					success: function (data, status) {
+						placeholder.replaceWith(data);
+					}
+				});
+			});
+		}
+	}
+
+})(jQuery);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/resources/js/zodbbrowser.min.js	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,1 @@
+(function(a){window.PyAMS_zodbbrowser={filterAll:function(){a(".filter").attr("checked",true);PyAMS_zodbbrowser.filterHistory(true)},filterNone:function(){a(".filter").attr("checked",false);PyAMS_zodbbrowser.filterHistory()},filterHistory:function(k){var p=a("div.transaction");var g=a(".filter");var b=Array();var c="map";for(var n=0;n<g.length;n++){var e=g[n];if(e.checked){b[c+e.name]=e.checked}}for(n=0;n<p.length;n++){var h=p[n];var o=a(h).children("div.diff").children("div.diffitem");var f=0;for(var m=0;m<o.length;m++){var d=a(a(o[m]).children()[0]).text();if(c+d in b||k){a(o[m]).show()}else{a(o[m]).hide();f+=1}}var l=null;if(f==1){l="1 item hidden"}else{if(f){l=f+" items hidden"}}a(h).children(".filtered").remove();if(l){a(h).append('<div class="filtered">'+l+"</div>")}}},collapseOrExpand:function(){var c=a(this).next();var b=a(this).children("img");if(c.is(":hidden")){a(b).attr("src",a("#collapseImg").attr("src"));c.slideDown()}else{a(b).attr("src",a("#expandImg").attr("src"));c.slideUp()}},hideItemsIfTooMany:function(){a(".items").each(function(){var d=a(this).children(".expander")[0];var c=a(this).children(".collapsible")[0];if(c.childNodes.length>100&&!a(c).is(":hidden")){var b=a(d).children("img");a(b).attr("src",a("#expandImg").attr("src"));a(c).hide()}})},showGoTo:function(){a("#path").hide();a("#goto").show();a("#gotoInput").focus()},hideGoTo:function(){a("#goto").hide();a("#path").show();setTimeout(function(){a("#pathError").slideUp()},50)},ajaxErrorHandler:function(b,d,c){errorMessage="";if(d=="parsererror"){errorMessage="Server returned malformed data"}else{if(d=="error"){errorMessage="Unknown error (maybe server is offline?)"}else{if(d=="timeout"){errorMessage="Server timeout"}else{if(d=="notmodified"){errorMessage="Server error (says resource not modified)"}else{errorMessage="Unknown error"}}}}errorMessage='<span class="error"> '+errorMessage+"</strong>";a("#pathError").html(errorMessage)},ajaxSuccessHandler:function(c,b){if(c.url){window.location=c.url;a("#pathError").text("Found.").slideDown().slideUp()}else{if(c.error){a("#pathError").text(c.error).show();if(c.partial_url){a("#pathError").append(', would you like to <a href="'+c.partial_url+'">go to '+c.partial_path+"</a> instead?")}}else{a("#pathError").text(b).show()}}},activateGoTo:function(){var c=a("#gotoInput").val();var b="zodbbrowser_path_to_oid";a("#pathError").text("Loading...").slideDown();a.ajax({url:b,dataType:"json",data:"path="+c,timeout:7000,success:PyAMS_zodbbrowser.ajaxSuccessHandler,error:PyAMS_zodbbrowser.ajaxErrorHandler})},cancelRollback:function(b){a("#confirmation").remove();a("div.transaction").removeClass("focus");a("input.rollbackbtn").show()},pressRollback:function(c){c.preventDefault();PyAMS_zodbbrowser.cancelRollback();a(c.target).hide();var b=a(c.target).closest("div.transaction");b.addClass("focus");a('<div id="confirmation"><form action="" method="post"><div class="message">This is a dangerous operation that may break data integrity. Are you really sure you want to do this?</div><input type="BUTTON" value="Really revert to this state" onclick="PyAMS_zodbbrowser.doRollback()"><input type="BUTTON" value="Cancel" onclick="PyAMS_zodbbrowser.cancelRollback()"></form></div>').appendTo(b)},doRollback:function(){var b=a("#confirmation").closest("div.transaction");var c=b.find("form.rollback");c.find('input[name="confirmed"]').val("1");c.submit()},init:function(b){a(".expander",b).click(PyAMS_zodbbrowser.collapseOrExpand);PyAMS_zodbbrowser.hideItemsIfTooMany();a("#path a",b).click(function(c){c.stopPropagation()});a("#path",b).click(PyAMS_zodbbrowser.showGoTo);a("#gotoInput",b).blur(PyAMS_zodbbrowser.hideGoTo);a("#gotoInput",b).keypress(function(c){if(c.which==13){PyAMS_zodbbrowser.activateGoTo()}});a("#gotoInput",b).keydown(function(c){if(c.keyCode==27){PyAMS_zodbbrowser.hideGoTo()}});a(document).keypress(function(c){if(c.which==103){if(a("#goto",b).is(":hidden")){PyAMS_zodbbrowser.showGoTo();c.preventDefault()}}});a("input.rollbackbtn",b).click(PyAMS_zodbbrowser.pressRollback);a("span.truncated",b).click(function(c){c.preventDefault();var d=a(this);var e=d.attr("id");a.ajax({url:"zodbbrowser_truncated",data:"id="+e,success:function(g,f){d.replaceWith(g)}})})}}})(jQuery);
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/templates/zodbhistory.pt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,78 @@
+<div class="zodbbrowser"
+	 data-ams-plugins="zodbbrowser"
+	 data-ams-plugin-zodbbrowser-src="/--static--/pyams_zodbbrowser/js/zodbbrowser.js"
+	 data-ams-plugin-zodbbrowser-css="/--static--/pyams_zodbbrowser/css/zodbbrowser.css">
+	<div class="heading">
+		<h1 id="path">
+			ZODB transactions
+		</h1>
+
+		<h1 id="goto" style="display:none">
+			<input type="hidden" id="api" name="api" value="#zodbbrowser_path_to_oid" />
+			<input type="text" class="goto" id="gotoInput" name="goto" />
+		</h1>
+		<span id="pathError" style="display:none"></span>
+	</div>
+
+	<div class="history">
+		<div tal:condition="python: view.page > 0">
+			<h4 class="paging transaction" tal:define="prev_page python:view.page - 1">
+				<a class="title" tal:attributes="href string:#zodbbrowser_history?page=${prev_page}">Newer</a>
+			</h4>
+		</div>
+		<div tal:condition="python: view.page == 0"
+				tal:attributes="class python:'tid' in request.params and 'none' or 'latest'">
+			<h4 class="transaction">
+				<a class="title" href="#zodbbrowser_history">Latest</a>
+			</h4>
+		</div>
+		<div class="transaction" tal:repeat="history view.listHistory()"
+				tal:attributes="class python:history['current']
+				and 'transaction current' or 'transaction'">
+			<h4 class="transaction" tal:attributes="id string:tid${history.utid}">
+				<a class="title" tal:attributes="href history.href">
+					#<span tal:replace="history.index" />:
+					<span class="timestamp" tal:content="string:${history.utc_timestamp}" title="UTC" />
+					<span class="user" tal:content="history.user_id"
+							tal:attributes="title string:user from site ${history.user_location}" />
+					<span class="location" tal:content="history.location|nothing"
+				  			tal:attributes="title string:request type ${history.request_type|nothing}" />
+					<span class="description" tal:content="history.description" />
+				</a>
+			</h4>
+
+			<div class="diff">
+				<h5 class="expander">
+					<img src="/--static--/pyams_zodbbrowser/img/collapse.png"
+						 alt="collapse"
+							tal:condition="not:history.hidden"
+							/><img src="/--static--/pyams_zodbbrowser/img/expand.png"
+								   alt="expand"
+						tal:condition="history.hidden"
+						/>&nbsp;<span tal:replace="history.summary">N objects saved</span>
+				</h5>
+
+				<div class="collapsible"
+						tal:attributes="style python: history['hidden'] and 'display: none' or None">
+					<ol>
+						<li tal:repeat="obj history.objects">
+							<a tal:attributes="href obj.url" tal:content="obj.path">/(path)</a>
+							<span tal:replace="obj.class_repr" />
+							<a tal:attributes="href obj.url" tal:content="structure obj.repr">(obj)</a>
+						</li>
+					</ol>
+				</div>
+			</div>
+		</div>
+		<div tal:condition="python: view.page < view.last_page">
+			<h4 class="paging transaction" tal:define="next_page python:view.page + 1">
+				<a class="title" tal:attributes="href string:#zodbbrowser_history?page=${next_page}">Older</a>
+			</h4>
+		</div>
+	</div>
+
+</div>
+<img id="collapseImg" style="display:none" alt=""
+     src="/--static--/pyams_zodbbrowser/img/collapse.png" />
+<img id="expandImg" style="display:none" alt=""
+     src="/--static--/pyams_zodbbrowser/img/expand.png" />
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/templates/zodbinfo.pt	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,143 @@
+<div class="zodbbrowser"
+	 data-ams-plugins="zodbbrowser"
+	 data-ams-plugin-zodbbrowser-src="/--static--/pyams_zodbbrowser/js/zodbbrowser.js"
+	 data-ams-plugin-zodbbrowser-css="/--static--/pyams_zodbbrowser/css/zodbbrowser.css"
+	 data-ams-plugin-zodbbrowser-callback="PyAMS_zodbbrowser.init">
+	<div class="heading">
+		<h1 id="path">
+			<span class="breadcrumbs" tal:content="structure view.getBreadcrumbsHTML()" />
+		</h1>
+
+		<h1 id="goto" style="display:none">
+			<input type="text" class="goto" id="gotoInput" name="goto"
+					tal:attributes="value view.getPath()"/>
+		</h1>
+		<span id="pathError" style="display:none"></span>
+		<span class="pickleSize"> <span tal:replace="view.getPickleSize()" /> bytes pickled</span>
+
+		<h2 class="type" tal:content="view.getObjectType()" />
+
+		<div class="tid-info" tal:condition="not:view.latest">
+			at <a tal:attributes="href string:#zodbbrowser_history?tid=${view.getStateTid()}"
+				tal:content="view.getStateTidNice()"></a>
+			<tal:span tal:content="string:(last change before or at ${view.getRequestedTidNice()})"/>
+		</div>
+	</div>
+
+	<div class="attributes"
+			tal:define="attributes view.listAttributes();
+					error view.state.getError()"
+			tal:condition="python:attributes is not None or error">
+		<h3 class="expander">
+			<img src="/--static--/pyams_zodbbrowser/img/collapse.png"
+				 alt="collapse" />&nbsp;Attributes
+		</h3>
+
+		<div class="collapsible">
+			<tal:block tal:condition="error">
+				<div class="error" tal:condition="error">
+					Could not load the object:
+					<span tal:replace="error" />
+				</div>
+			</tal:block>
+			<tal:block tal:condition="not:attributes">
+				<span tal:condition="not:error" class="empty">There are none.</span>
+			</tal:block>
+			<tal:block tal:condition="attributes">
+				<tal:block tal:repeat="attr attributes">
+					<input type="checkbox" class="filter" checked="checked"
+						   onchange="PyAMS_zodbbrowser.filterHistory()"
+							tal:attributes="name string:${attr.name}"/>
+					<span class="attr">
+						<strong tal:content="attr.name" />:
+						<tal:block replace="structure attr.rendered_value()" />
+					</span>
+					<br />
+				</tal:block>
+				<div class="buttons">
+					<a class="jsbutton" href="javascript:PyAMS_zodbbrowser.filterAll()"
+							>show all</a>
+					<a class="jsbutton" href="javascript:PyAMS_zodbbrowser.filterNone()"
+							>hide all</a>
+				</div>
+			</tal:block>
+		</div>
+	</div>
+
+	<div class="items"
+			tal:define="items view.listItems()"
+			tal:condition="python:items is not None">
+		<h3 class="expander">
+			<img src="/--static--/pyams_zodbbrowser/img/collapse.png"
+				 alt="collapse" />&nbsp;Items (<span tal:replace="python: len(items)"></span>)
+		</h3>
+
+		<div class="collapsible">
+			<tal:block tal:condition="not:items">
+				<span class="empty">There are none.</span>
+			</tal:block>
+			<tal:block tal:repeat="item items">
+				<strong tal:content="item.name" />:
+				<tal:block replace="structure item.rendered_value()" />
+				<br />
+			</tal:block>
+		</div>
+	</div>
+
+	<div class="history"
+			tal:define="history view.listHistory()"
+			tal:condition="history">
+		<h3 class="expander">
+			<img src="/--static--/pyams_zodbbrowser/img/collapse.png"
+				 alt="collapse" />&nbsp;History
+		</h3>
+
+		<div class="collapsible">
+			<div tal:attributes="class python:view.getRequestedTid() and 'none' or 'latest'">
+				<h4 class="transaction">
+					<a class="title" tal:attributes="href string:#zodbbrowser?oid=${view.getObjectIdHex()}"
+							>Latest</a>
+				</h4>
+			</div>
+			<div class="transaction" tal:repeat="history history"
+					tal:attributes="class python:(history['current'] or repeat['history'].start() and not view.getRequestedTid())
+					and 'transaction current' or 'transaction'">
+				<h4 class="transaction" tal:attributes="id string:tid${history.utid}">
+					<a class="subtitle"
+							tal:attributes="href string:#zodbbrowser_history?tid=${history.utid}">view transaction
+						record</a>
+					<a class="title" tal:attributes="href history.href">
+						#<span tal:replace="history.index" />:
+						<span class="timestamp" tal:content="string:${history.utc_timestamp}" title="UTC" />
+            <span class="user" tal:content="history.user_id"
+					tal:attributes="title string:user from site ${history.user_location}" />
+            <span class="location" tal:content="history.location|nothing"
+					tal:attributes="title string:request type ${history.request_type|nothing}" />
+						<span class="description" tal:content="history.description" />
+					</a>
+				</h4>
+
+				<div class="toolbox" tal:condition="python: not repeat['history'].start() and not view.readonly">
+					<form action="zodbbrowser" class="rollback" method="post" data-async>
+						<input type="hidden" name="oid" tal:attributes="value view.getObjectId()" />
+						<input type="hidden" name="tid" tal:attributes="value view.getRequestedTid()" />
+						<input type="hidden" name="rtid" tal:attributes="value history.utid" />
+						<input type="hidden" name="confirmed" value="0" />
+						<input type="hidden" name="ROLLBACK" value="" />
+						<input type="submit" class="rollbackbtn" value="Revert to this state" />
+					</form>
+				</div>
+				<div class="error" tal:condition="history.error">
+					Could not load historical state:
+					<span tal:replace="history.error" />
+				</div>
+				<div class="diff" tal:replace="structure history.diff">
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+<img id="collapseImg" style="display:none" alt=""
+     src="/--static--/pyams_zodbbrowser/img/collapse.png" />
+<img id="expandImg" style="display:none" alt=""
+     src="/--static--/pyams_zodbbrowser/img/expand.png" />
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_zodbbrowser/zmi/views.py	Wed Mar 11 12:27:00 2015 +0100
@@ -0,0 +1,568 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyramid.httpexceptions import HTTPFound
+from pyramid.view import view_config
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import json
+import time
+import transaction
+from html import escape
+
+# import interfaces
+from pyams_skin.interfaces import IInnerPage
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces.menu import IControlPanelMenu
+from pyams_zmi.layer import IAdminLayer
+from pyams_zodbbrowser.interfaces import IValueRenderer, IDatabaseHistory
+
+# import packages
+from BTrees.utils import oid_repr
+from persistent import Persistent
+from persistent.timestamp import TimeStamp
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_template.template import template_config
+from pyams_utils.adapter import ContextRequestAdapter
+from pyams_utils.interfaces import PYAMS_APPLICATION_DEFAULT_NAME, PYAMS_APPLICATION_SETTINGS_KEY
+from pyams_utils.property import cached_property
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zodbbrowser.diff import compareDictsHTML
+from pyams_zodbbrowser.history import ZodbObjectHistory
+from pyams_zodbbrowser.state import ZodbObjectState
+from pyams_zodbbrowser.value import TRUNCATIONS, pruneTruncations
+from ZODB.POSException import POSKeyError
+from ZODB.utils import p64, u64, tid_repr
+from zope.exceptions.interfaces import UserError
+from zope.interface import implementer, Interface
+
+from pyams_zodbbrowser import _
+
+
+def getObjectType(obj):
+    cls = getattr(obj, '__class__', None)
+    if type(obj) is not cls:
+        return '%s - %s' % (type(obj), cls)
+    else:
+        return str(cls)
+
+
+def getObjectTypeShort(obj):
+    cls = getattr(obj, '__class__', None)
+    if type(obj) is not cls:
+        return '%s - %s' % (type(obj).__name__, cls.__name__)
+    else:
+        return cls.__name__
+
+
+def getObjectPath(obj, tid):
+    path = []
+    seen_root = False
+    state = ZodbObjectState(obj, tid)
+    while True:
+        if state.isRoot():
+            path.append('/')
+            seen_root = True
+        else:
+            if path:
+                path.append('/')
+            if not state.getName() and state.getParentState() is None:
+                # not using hex() because we don't want L suffixes for
+                # 64-bit values
+                path.append('0x%x' % state.getObjectId())
+                break
+            path.append(state.getName() or '???')
+        state = state.getParentState()
+        if state is None:
+            if not seen_root:
+                path.append('/')
+                path.append('...')
+                path.append('/')
+            break
+    return ''.join(path[::-1])
+
+
+class ZodbObjectAttribute(object):
+
+    def __init__(self, name, value, tid=None):
+        self.name = name
+        self.value = value
+        self.tid = tid
+
+    def rendered_name(self):
+        return IValueRenderer(self.name).render(self.tid)
+
+    def rendered_value(self):
+        return IValueRenderer(self.value).render(self.tid)
+
+    def __repr__(self):
+        return '%s(%r, %r, %r)' % (self.__class__.__name__, self.name,
+                                   self.value, self.tid)
+
+    def __eq__(self, other):
+        if self.__class__ != other.__class__:
+            return False
+        return (self.name, self.value, self.tid) == (other.name, other.value,
+                                                     other.tid)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+
+@viewlet_config(name='zodbbrowser.menu', layer=IAdminLayer, context=Interface, manager=IControlPanelMenu,
+                permission='system.manage', weight=9999)
+class ZODBBrowserMenu(MenuItem):
+    """ZODB browser menu"""
+
+    label = _("ZODB browser")
+    icon_class = 'fa fa-fw fa-database'
+    url = '#zodbbrowser'
+
+
+class VeryCarefulView(ContextRequestAdapter):
+    """Base ZODB view"""
+
+    made_changes = False
+
+    @cached_property
+    def jar(self):
+        try:
+            return self.request.annotations['ZODB.interfaces.IConnection']
+        except (KeyError, AttributeError):
+            obj = self.findClosestPersistent()
+            if obj is None:
+                raise Exception("ZODB connection not available for this request")
+            return obj._p_jar
+
+    @property
+    def readonly(self):
+        return self.jar.isReadOnly()
+
+    def findClosestPersistent(self):
+        obj = self.context
+        while not isinstance(obj, Persistent):
+            try:
+                obj = obj.__parent__
+            except AttributeError:
+                return None
+        return obj
+
+
+@pagelet_config(name='zodbbrowser', context=Interface, layer=IPyAMSLayer, permission='system.manage')
+@template_config(template='templates/zodbinfo.pt', layer=IPyAMSLayer)
+@implementer(IInnerPage)
+class ZodbInfoView(VeryCarefulView):
+    """ZODB info view"""
+
+    def update(self):
+        super(ZodbInfoView, self).update()
+        pruneTruncations()
+        self.obj = self.selectObjectToView()
+        # Not using IObjectHistory(self.obj) because LP#1185175
+        self.history = ZodbObjectHistory(self.obj)
+        self.latest = True
+        if self.request.params.get('tid'):
+            self.state = ZodbObjectState(self.obj,
+                                         p64(int(self.request.params['tid'], 0)),
+                                         _history=self.history)
+            self.latest = False
+        else:
+            self.state = ZodbObjectState(self.obj, _history=self.history)
+
+        if 'CANCEL' in self.request.params:
+            raise self._redirectToSelf()
+
+        if 'ROLLBACK' in self.request.params:
+            rtid = p64(int(self.request.params['rtid'], 0))
+            self.requestedState = self._tidToTimestamp(rtid)
+            if self.request.params.get('confirmed') == '1':
+                self.history.rollback(rtid)
+                transaction.get().note('Rollback to old state %s'
+                                       % self.requestedState)
+                self.made_changes = True
+                transaction.get().commit()
+                raise self._redirectToSelf()
+
+    def _redirectToSelf(self):
+        return HTTPFound(self.getUrl())
+
+    def selectObjectToView(self):
+        params = self.request.params
+        obj = None
+        if 'oid' not in params:
+            obj = self.findClosestPersistent()
+            # Sanity check: if we're running in standalone mode,
+            # self.context is a Folder in the just-created MappingStorage,
+            # which we're not interested in.
+            if obj is not None and obj._p_jar is not self.jar:
+                obj = None
+        if obj is None:
+            if 'oid' in params:
+                try:
+                    oid = int(params['oid'], 0)
+                except ValueError:
+                    raise UserError('OID is not an integer: %r' % params['oid'])
+            else:
+                oid = self.getRootOid()
+            try:
+                obj = self.jar.get(p64(oid))
+            except POSKeyError:
+                raise UserError('There is no object with OID 0x%x' % oid)
+        return obj
+
+    def getRequestedTid(self):
+        if 'tid' in self.request.params:
+            return self.request.params['tid']
+        else:
+            return None
+
+    def getRequestedTidNice(self):
+        if 'tid' in self.request.params:
+            return self._tidToTimestamp(p64(int(self.request.params['tid'], 0)))
+        else:
+            return None
+
+    def getObjectId(self):
+        return self.state.getObjectId()
+
+    def getObjectIdHex(self):
+        return '0x%x' % self.state.getObjectId()
+
+    def getObjectType(self):
+        return getObjectType(self.obj)
+
+    def getObjectTypeShort(self):
+        return getObjectTypeShort(self.obj)
+
+    def getStateTid(self):
+        return u64(self.state.tid)
+
+    def getStateTidNice(self):
+        return self._tidToTimestamp(self.state.tid)
+
+    def getPickleSize(self):
+        return len(self.state.pickledState)
+
+    def getRootOid(self):
+        root = self.jar.root()
+        try:
+            settings = self.request.registry.settings
+            root = root[settings.get(PYAMS_APPLICATION_SETTINGS_KEY, PYAMS_APPLICATION_DEFAULT_NAME)]
+        except KeyError:
+            pass
+        return u64(root._p_oid)
+
+    def locate(self, path):
+        not_found = object()  # marker
+
+        # our current position
+        #   partial -- path of the last _persistent_ object
+        #   here -- path of the last object traversed
+        #   oid -- oid of the last _persistent_ object
+        #   obj -- last object traversed
+        partial = here = '/'
+        oid = self.getRootOid()
+        obj = self.jar.get(p64(oid))
+
+        steps = path.split('/')
+
+        if steps and steps[0]:
+            # 0x1234/sub/path -> start traversal at oid 0x1234
+            try:
+                oid = int(steps[0], 0)
+            except ValueError:
+                pass
+            else:
+                partial = here = hex(oid)
+                try:
+                    obj = self.jar.get(p64(oid))
+                except KeyError:
+                    oid = self.getRootOid()
+                    return dict(error='Not found: %s' % steps[0],
+                                partial_oid=oid,
+                                partial_path='/',
+                                partial_url=self.getUrl(oid))
+                steps = steps[1:]
+
+        for step in steps:
+            if not step:
+                continue
+            if not here.endswith('/'):
+                here += '/'
+            here += step.encode('utf-8')
+            try:
+                child = obj[step]
+            except Exception:
+                child = getattr(obj, step, not_found)
+                if child is not_found:
+                    return dict(error='Not found: %s' % here,
+                                partial_oid=oid,
+                                partial_path=partial,
+                                partial_url=self.getUrl(oid))
+            obj = child
+            if isinstance(obj, Persistent):
+                partial = here
+                oid = u64(obj._p_oid)
+        if not isinstance(obj, Persistent):
+            return dict(error='Not persistent: %s' % here,
+                        partial_oid=oid,
+                        partial_path=partial,
+                        partial_url=self.getUrl(oid))
+        return dict(oid=oid,
+                    url=self.getUrl(oid))
+
+    def getUrl(self, oid=None, tid=None):
+        if oid is None:
+            oid = self.getObjectId()
+        url = "#zodbbrowser?oid=0x%x" % oid
+        if tid is None and 'tid' in self.request.params:
+            url += "&tid=" + self.request.params['tid']
+        elif tid is not None:
+            url += "&tid=0x%x" % tid
+        return url
+
+    def getBreadcrumbs(self):
+        breadcrumbs = []
+        state = self.state
+        seen_root = False
+        while True:
+            url = self.getUrl(state.getObjectId())
+            if state.isRoot():
+                breadcrumbs.append(('/', url))
+                seen_root = True
+            else:
+                if breadcrumbs:
+                    breadcrumbs.append(('/', None))
+                if not state.getName() and state.getParentState() is None:
+                    # not using hex() because we don't want L suffixes for
+                    # 64-bit values
+                    breadcrumbs.append(('0x%x' % state.getObjectId(), url))
+                    break
+                breadcrumbs.append((state.getName() or '???', url))
+            state = state.getParentState()
+            if state is None:
+                if not seen_root:
+                    url = self.getUrl(self.getRootOid())
+                    breadcrumbs.append(('/', None))
+                    breadcrumbs.append(('...', None))
+                    breadcrumbs.append(('/', url))
+                break
+        return breadcrumbs[::-1]
+
+    def getPath(self):
+        return ''.join(name for name, url in self.getBreadcrumbs())
+
+    def getBreadcrumbsHTML(self):
+        html = []
+        for name, url in self.getBreadcrumbs():
+            if url:
+                html.append('<a href="%s">%s</a>' % (escape(url, True),
+                                                     escape(name)))
+            else:
+                html.append(escape(name))
+        return ''.join(html)
+
+    def listAttributes(self):
+        attrs = self.state.listAttributes()
+        if attrs is None:
+            return None
+        return [ZodbObjectAttribute(name, value, self.state.requestedTid)
+                for name, value in sorted(attrs)]
+
+    def listItems(self):
+        items = self.state.listItems()
+        if items is None:
+            return None
+        return [ZodbObjectAttribute(name, value, self.state.requestedTid)
+                for name, value in items]
+
+    def _loadHistoricalState(self):
+        results = []
+        for d in self.history:
+            try:
+                interp = ZodbObjectState(self.obj, d['tid'],
+                                         _history=self.history)
+                state = interp.asDict()
+                error = interp.getError()
+            except Exception as e:
+                state = {}
+                error = '%s: %s' % (e.__class__.__name__, e)
+            results.append(dict(state=state, error=error))
+        results.append(dict(state={}, error=None))
+        return results
+
+    def listHistory(self):
+        """List transactions that modified a persistent object."""
+        state = self._loadHistoricalState()
+        results = []
+        for n, d in enumerate(self.history):
+            utc_timestamp = str(time.strftime('%Y-%m-%d %H:%M:%S',
+                                              time.gmtime(d['time'])))
+            local_timestamp = str(time.strftime('%Y-%m-%d %H:%M:%S',
+                                                time.localtime(d['time'])))
+            try:
+                user_location, user_id = d['user_name'].split()
+            except ValueError:
+                user_location = None
+                user_id = d['user_name']
+            url = self.getUrl(tid=u64(d['tid']))
+            current = (d['tid'] == self.state.tid and
+                       self.state.requestedTid is not None)
+            curState = state[n]['state']
+            oldState = state[n + 1]['state']
+            diff = compareDictsHTML(curState, oldState, d['tid'])
+
+            results.append(dict(utid=u64(d['tid']),
+                                href=url, current=current,
+                                error=state[n]['error'],
+                                diff=diff, user_id=user_id,
+                                user_location=user_location,
+                                utc_timestamp=utc_timestamp,
+                                local_timestamp=local_timestamp, **d))
+
+        # number in reverse order
+        for i in range(len(results)):
+            results[i]['index'] = len(results) - i
+
+        return results
+
+    def _tidToTimestamp(self, tid):
+        if isinstance(tid, str) and len(tid) == 8:
+            return str(TimeStamp(tid))
+        return tid_repr(tid)
+
+
+@view_config(name='zodbbrowser_path_to_oid', context=Interface, request_type=IPyAMSLayer,
+             permission='system.manage', renderer='json', xhr=True)
+class PathToOidView(ZodbInfoView):
+
+    def __call__(self):
+        path = self.request.params.get('path')
+        return self.locate(path)
+
+
+@view_config(name='zodbbrowser_truncated', context=Interface, request_type=IPyAMSLayer,
+             permission='system.manage', renderer='json', xhr=True)
+class TruncatedView(ZodbInfoView):
+
+    def __call__(self):
+        id = self.request.params.get('id')
+        return TRUNCATIONS.get(id)
+
+
+@pagelet_config(name='zodbbrowser_history', context=Interface, layer=IPyAMSLayer, permission='system.manage')
+@template_config(template='templates/zodbhistory.pt', layer=IPyAMSLayer)
+@implementer(IInnerPage)
+class ZodbHistoryView(VeryCarefulView):
+    """Zodb history view"""
+
+    page_size = 5
+
+    def update(self):
+        super(ZodbHistoryView, self).update()
+        pruneTruncations()
+        params = self.request.params
+        if 'page_size' in params:
+            self.page_size = max(1, int(params['page_size']))
+        self.history = IDatabaseHistory(self.jar)
+        if 'page' in params:
+            self.page = int(params['page'])
+        elif 'tid' in params:
+            tid = int(params['tid'], 0)
+            self.page = self.findPage(p64(tid))
+        else:
+            self.page = 0
+        self.last_page = max(0, len(self.history) - 1) // self.page_size
+        if self.page > self.last_page:
+            self.page = self.last_page
+        self.last_idx = max(0, len(self.history) - self.page * self.page_size)
+        self.first_idx = max(0, self.last_idx - self.page_size)
+
+    def getUrl(self, tid=None):
+        url = "#zodbbrowser_history"
+        if tid is None and 'tid' in self.request.params:
+            url += "?tid=" + self.request.params['tid']
+        elif tid is not None:
+            url += "?tid=0x%x" % tid
+        return url
+
+    def findPage(self, tid):
+        try:
+            pos = list(self.history.tids).index(tid)
+        except ValueError:
+            return 0
+        else:
+            return (len(self.history) - pos - 1) // self.page_size
+
+    def listHistory(self):
+        if 'tid' in self.request.params:
+            requested_tid = p64(int(self.request.params['tid'], 0))
+        else:
+            requested_tid = None
+
+        results = []
+        for n, d in enumerate(self.history[self.first_idx:self.last_idx]):
+            utid = u64(d.tid)
+            ts = TimeStamp(d.tid).timeTime()
+            utc_timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(ts))
+            local_timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))
+            try:
+                user_location, user_id = d.user.split()
+            except ValueError:
+                user_location = None
+                user_id = d.user
+            try:
+                size = d._tend - d._tpos
+            except AttributeError:
+                size = None
+            ext = d.extension if isinstance(d.extension, dict) else {}
+            objects = []
+            for record in d:
+                obj = self.jar.get(record.oid)
+                url = "#zodbbrowser?oid=0x%x&tid=0x%x" % (u64(record.oid),
+                                                           utid)
+                objects.append(dict(
+                    oid=u64(record.oid),
+                    path=getObjectPath(obj, d.tid),
+                    oid_repr=oid_repr(record.oid),
+                    class_repr=getObjectType(obj),
+                    url=url,
+                    repr=IValueRenderer(obj).render(d.tid),
+                ))
+            if len(objects) == 1:
+                summary = '1 object record'
+            else:
+                summary = '%d object records' % len(objects)
+            if size is not None:
+                summary += ' (%d bytes)' % size
+            results.append(dict(
+                index=(self.first_idx + n + 1),
+                utc_timestamp=utc_timestamp,
+                local_timestamp=local_timestamp,
+                user_id=user_id,
+                user_location=user_location,
+                description=d.description,
+                utid=utid,
+                current=(d.tid == requested_tid),
+                href=self.getUrl(tid=utid),
+                size=size,
+                summary=summary,
+                hidden=(len(objects) > 5),
+                objects=objects,
+                **ext
+            ))
+        if results and not requested_tid and self.page == 0:
+            results[-1]['current'] = True
+        return results[::-1]