From 64792c1e72030874d29ca932a256b9a914eb8202 Mon Sep 17 00:00:00 2001
From: Demian Katz <demian.katz@villanova.edu>
Date: Tue, 20 Dec 2016 12:56:27 -0500
Subject: [PATCH] Add RecordDataFormatter helper to simplify description
 template. (#829)

- Replaces extremely redundant template logic with a view helper driven by a simple, declarative configuration.
- Injects default configurations into the view helper, so that changes (adding/removing/reordering fields) can be made by customizing a factory OR by customizing templates, depending on user preferences.
- Includes a class for managing configurations, to simplify common setup tasks.
- Keeps all data retrieval logic in the record driver, but allows extremely granular display customization on a field-by-field and driver-by-driver basis using driver-specific templates.
---
 .../src/VuFind/View/Helper/Root/Record.php    |   2 +-
 .../View/Helper/Root/RecordDataFormatter.php  | 275 ++++++++++++++++++
 .../Root/RecordDataFormatter/SpecBuilder.php  | 136 +++++++++
 .../Root/RecordDataFormatterFactory.php       | 161 ++++++++++
 .../RecordDataFormatter/SpecBuilderTest.php   |  86 ++++++
 .../Helper/Root/RecordDataFormatterTest.php   | 192 ++++++++++++
 .../RecordDriver/SolrDefault/core.phtml       | 245 +---------------
 .../SolrDefault/data-allRecordLinks.phtml     |  11 +
 .../SolrDefault/data-allSubjectHeadings.phtml |  11 +
 .../SolrDefault/data-authorNotes.phtml        |   8 +
 .../SolrDefault/data-authors.phtml            |  15 +
 .../SolrDefault/data-childRecords.phtml       |   1 +
 .../SolrDefault/data-containerTitle.phtml     |  10 +
 .../SolrDefault/data-onlineAccess.phtml       |  14 +
 .../SolrDefault/data-publicationDetails.phtml |  14 +
 .../SolrDefault/data-series.phtml             |  16 +
 .../RecordDriver/SolrDefault/data-tags.phtml  |  15 +
 .../templates/RecordTab/description.phtml     | 236 +--------------
 themes/root/theme.config.php                  |   1 +
 19 files changed, 985 insertions(+), 464 deletions(-)
 create mode 100644 module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php
 create mode 100644 module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter/SpecBuilder.php
 create mode 100644 module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatterFactory.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatter/SpecBuilderTest.php
 create mode 100644 module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allRecordLinks.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allSubjectHeadings.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authorNotes.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authors.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-childRecords.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-containerTitle.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-onlineAccess.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-publicationDetails.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-series.phtml
 create mode 100644 themes/bootstrap3/templates/RecordDriver/SolrDefault/data-tags.phtml

diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Record.php b/module/VuFind/src/VuFind/View/Helper/Root/Record.php
index 7dd53c668dd..c9bf3514ca1 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/Record.php
+++ b/module/VuFind/src/VuFind/View/Helper/Root/Record.php
@@ -100,7 +100,7 @@ class Record extends AbstractHelper
      *
      * @return string
      */
-    protected function renderTemplate($name, $context = null)
+    public function renderTemplate($name, $context = null)
     {
         // Set default context if none provided:
         if (is_null($context)) {
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php
new file mode 100644
index 00000000000..6e627ca7fe2
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php
@@ -0,0 +1,275 @@
+<?php
+/**
+ * Record driver data formatting view helper
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:architecture:record_data_formatter
+ * Wiki
+ */
+namespace VuFind\View\Helper\Root;
+use VuFind\RecordDriver\AbstractBase as RecordDriver;
+use Zend\View\Helper\AbstractHelper;
+
+/**
+ * Record driver data formatting view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:architecture:record_data_formatter
+ * Wiki
+ */
+class RecordDataFormatter extends AbstractHelper
+{
+    /**
+     * Default settings.
+     *
+     * @var array
+     */
+    protected $defaults = [];
+
+    /**
+     * Sort callback for field specification.
+     *
+     * @param array $a First value to compare
+     * @param array $b Second value to compare
+     *
+     * @return int
+     */
+    public function specSortCallback($a, $b)
+    {
+        $posA = isset($a['pos']) ? $a['pos'] : 0;
+        $posB = isset($b['pos']) ? $b['pos'] : 0;
+        if ($posA === $posB) {
+            return 0;
+        }
+        return $posA < $posB ? -1 : 1;
+    }
+
+    /**
+     * Create formatted key/value data based on a record driver and field spec.
+     *
+     * @param RecordDriver $driver Record driver object.
+     * @param array        $spec   Formatting specification
+     *
+     * @return Record
+     */
+    public function getData(RecordDriver $driver, array $spec)
+    {
+        $result = [];
+
+        // Sort the spec into order by position:
+        uasort($spec, [$this, 'specSortCallback']);
+
+        // Apply the spec:
+        foreach ($spec as $field => $current) {
+            // Extract the relevant data from the driver.
+            $data = $this->extractData($driver, $current);
+            $allowZero = isset($current['allowZero']) ? $current['allowZero'] : true;
+            if (!empty($data) || ($allowZero && ($data === 0 || $data === '0'))) {
+                // Determine the rendering method to use with the second element
+                // of the current spec.
+                $renderMethod = empty($current['renderType'])
+                    ? 'renderSimple' : 'render' . $current['renderType'];
+
+                // Add the rendered data to the return value if it is non-empty:
+                if (is_callable([$this, $renderMethod])) {
+                    $text = $this->$renderMethod($driver, $data, $current);
+                    if (!$text && (!$allowZero || ($text !== 0 && $text !== '0'))) {
+                        continue;
+                    }
+                    // Allow dynamic label override:
+                    if (isset($current['labelFunction'])
+                        && is_callable($current['labelFunction'])
+                    ) {
+                        $field = call_user_func($current['labelFunction'], $data);
+                    }
+                    $result[$field] = $text;
+                }
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Get default configuration.
+     *
+     * @param string $key Key for configuration to look up.
+     *
+     * @return array
+     */
+    public function getDefaults($key)
+    {
+        return isset($this->defaults[$key]) ? $this->defaults[$key] : [];
+    }
+
+    /**
+     * Set default configuration.
+     *
+     * @param string $key    Key for configuration to set.
+     * @param array  $values Defaults to store.
+     *
+     * @return void
+     */
+    public function setDefaults($key, array $values)
+    {
+        $this->defaults[$key] = $values;
+    }
+
+    /**
+     * Extract data (usually from the record driver).
+     *
+     * @param RecordDriver $driver  Record driver
+     * @param array        $options Incoming options
+     *
+     * @return mixed
+     */
+    protected function extractData(RecordDriver $driver, array $options)
+    {
+        // Static cache for persisting data.
+        static $cache = [];
+
+        // If $method is a bool, return it as-is; this allows us to force the
+        // rendering (or non-rendering) of particular data independent of the
+        // record driver.
+        $method = isset($options['dataMethod']) ? $options['dataMethod'] : false;
+        if ($method === true || $method === false) {
+            return $method;
+        }
+
+        $useCache = isset($options['cacheData']) && $options['cacheData'];
+
+        if ($useCache) {
+            $cacheKey = $driver->getUniqueID() . '|'
+                . $driver->getSourceIdentifier() . '|' . $method;
+            if (isset($cache[$cacheKey])) {
+                return $cache[$cacheKey];
+            }
+        }
+
+        // Default action: try to extract data from the record driver:
+        $data = $driver->tryMethod($method);
+
+        if ($useCache) {
+            $cache[$cacheKey] = $data;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Render using the record view helper.
+     *
+     * @param RecordDriver $driver  Reoord driver object.
+     * @param mixed        $data    Data to render
+     * @param array        $options Rendering options.
+     *
+     * @return string
+     */
+    protected function renderRecordHelper(RecordDriver $driver, $data,
+        array $options
+    ) {
+        $method = isset($options['helperMethod']) ? $options['helperMethod'] : null;
+        $plugin = $this->getView()->plugin('record');
+        if (empty($method) || !is_callable([$plugin, $method])) {
+            throw new \Exception('Cannot call "' . $method . '" on helper.');
+        }
+        return $plugin($driver)->$method($data);
+    }
+
+    /**
+     * Render a record driver template.
+     *
+     * @param RecordDriver $driver  Reoord driver object.
+     * @param mixed        $data    Data to render
+     * @param array        $options Rendering options.
+     *
+     * @return string
+     */
+    protected function renderRecordDriverTemplate(RecordDriver $driver, $data,
+        array $options
+    ) {
+        if (!isset($options['template'])) {
+            throw new \Exception('Template option missing.');
+        }
+        $helper = $this->getView()->plugin('record');
+        $context = isset($options['context']) ? $options['context'] : [];
+        $context['driver'] = $driver;
+        $context['data'] = $data;
+        return trim(
+            $helper($driver)->renderTemplate($options['template'], $context)
+        );
+    }
+
+    /**
+     * Get a link associated with a value, or else return false if link does
+     * not apply.
+     *
+     * @param string $value   Value associated with link.
+     * @param array  $options Rendering options.
+     *
+     * @return string|bool
+     */
+    protected function getLink($value, $options)
+    {
+        if (isset($options['recordLink']) && $options['recordLink']) {
+            $helper = $this->getView()->plugin('record');
+            return $helper->getLink($options['recordLink'], $value);
+        }
+        return false;
+    }
+
+    /**
+     * Simple rendering method.
+     *
+     * @param RecordDriver $driver  Reoord driver object.
+     * @param mixed        $data    Data to render
+     * @param array        $options Rendering options.
+     *
+     * @return string
+     */
+    protected function renderSimple(RecordDriver $driver, $data, array $options)
+    {
+        $view = $this->getView();
+        $escaper = (isset($options['translate']) && $options['translate'])
+            ? $view->plugin('transEsc') : $view->plugin('escapeHtml');
+        $separator = isset($options['separator'])
+            ? $options['separator'] : '<br />';
+        $retVal = '';
+        $array = (array)$data;
+        $remaining = count($data);
+        foreach ($array as $line) {
+            $remaining--;
+            $text = $escaper($line);
+            $retVal .= ($link = $this->getLink($line, $options))
+                ? '<a href="' . $link . '">' . $text . '</a>' : $text;
+            if ($remaining > 0) {
+                $retVal .= $separator;
+            }
+        }
+        return (isset($options['prefix']) ? $options['prefix'] : '')
+            . $retVal
+            . (isset($options['suffix']) ? $options['suffix'] : '');
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter/SpecBuilder.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter/SpecBuilder.php
new file mode 100644
index 00000000000..aef39f81bd9
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter/SpecBuilder.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Specification builder for record driver data formatting view helper
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\View\Helper\Root\RecordDataFormatter;
+
+/**
+ * Specification builder for record driver data formatting view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SpecBuilder
+{
+    /**
+     * Spec
+     *
+     * @var array
+     */
+    protected $spec = [];
+
+    /**
+     * Highest position value so far.
+     *
+     * @var int
+     */
+    protected $maxPos = 0;
+
+    /**
+     * Constructor
+     *
+     * @param array $spec Existing specification lines (optional)
+     */
+    public function __construct($spec = [])
+    {
+        $this->spec = $spec;
+        foreach ($spec as $current) {
+            if (isset($current['pos']) && $current['pos'] > $this->maxPos) {
+                $this->maxPos = $current['pos'];
+            }
+        }
+    }
+    /**
+     * Set a generic spec line.
+     *
+     * @param string $key        Label to associate with this spec line
+     * @param string $dataMethod Method of data retrieval for rendering element
+     * @param string $renderType Type of rendering to use to generate output
+     * @param array  $options    Additional options
+     *
+     * @return array
+     */
+    public function setLine($key, $dataMethod, $renderType = null, $options = [])
+    {
+        $options['dataMethod'] = $dataMethod;
+        $options['renderType'] = $renderType;
+        if (!isset($options['pos'])) {
+            $this->maxPos += 100;
+            $options['pos'] = $this->maxPos;
+        }
+        $this->spec[$key] = $options;
+    }
+
+    /**
+     * Construct a record driver template spec line.
+     *
+     * @param string $key        Label to associate with this spec line
+     * @param string $dataMethod Method of data retrieval for rendering element
+     * @param string $template   Record driver template to render with data
+     * @param array  $options    Additional options
+     *
+     * @return array
+     */
+    public function setTemplateLine($key, $dataMethod, $template, $options = [])
+    {
+        $options['template'] = $template;
+        return $this->setLine($key, $dataMethod, 'RecordDriverTemplate', $options);
+    }
+
+    /**
+     * Reorder the specs to match the provided array of keys.
+     *
+     * @param array $orderedKeys Keys in the desired order
+     * @param int   $defaultPos  Position to use for elements not included in
+     * $orderedKeys (null to put unrecognized items at end of list).
+     *
+     * @return void
+     */
+    public function reorderKeys($orderedKeys, $defaultPos = null)
+    {
+        $lookup = array_flip($orderedKeys);
+        if (null === $defaultPos) {
+            $defaultPos = (max($lookup) + 2) * 100;
+        }
+        foreach ($this->spec as $key => $options) {
+            $this->spec[$key]['pos'] = isset($lookup[$key])
+                ? ($lookup[$key] + 1) * 100 : $defaultPos;
+        }
+    }
+
+    /**
+     * Get the spec.
+     *
+     * @return array
+     */
+    public function getArray()
+    {
+        return $this->spec;
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatterFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatterFactory.php
new file mode 100644
index 00000000000..1f355073207
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatterFactory.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * Factory for record driver data formatting view helper
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:architecture:record_data_formatter
+ * Wiki
+ */
+namespace VuFind\View\Helper\Root;
+
+/**
+ * Factory for record driver data formatting view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:architecture:record_data_formatter
+ * Wiki
+ */
+class RecordDataFormatterFactory
+{
+    /**
+     * Create the helper.
+     *
+     * @return RecordDataFormatter
+     */
+    public function __invoke()
+    {
+        $helper = new RecordDataFormatter();
+        $helper->setDefaults('core', $this->getDefaultCoreSpecs());
+        $helper->setDefaults('description', $this->getDefaultDescriptionSpecs());
+        return $helper;
+    }
+
+    /**
+     * Get default specifications for displaying data in core metadata.
+     *
+     * @return array
+     */
+    public function getDefaultCoreSpecs()
+    {
+        $spec = new RecordDataFormatter\SpecBuilder();
+        $spec->setTemplateLine(
+            'Published in', 'getContainerTitle', 'data-containerTitle.phtml'
+        );
+        $spec->setLine(
+            'New Title', 'getNewerTitles', null, ['recordLink' => 'title']
+        );
+        $spec->setLine(
+            'Previous Title', 'getPreviousTitles', null, ['recordLink' => 'title']
+        );
+        $spec->setTemplateLine(
+            'Main Authors', 'getDeduplicatedAuthors', 'data-authors.phtml',
+            [
+                'useCache' => true,
+                'labelFunction' => function ($data) {
+                    return count($data['main']) > 1
+                        ? 'Main Authors' : 'Main Author';
+                },
+                'context' => ['type' => 'main', 'schemaLabel' => 'author'],
+            ]
+        );
+        $spec->setTemplateLine(
+            'Corporate Authors', 'getDeduplicatedAuthors', 'data-authors.phtml',
+            [
+                'useCache' => true,
+                'labelFunction' => function ($data) {
+                    return count($data['corporate']) > 1
+                        ? 'Corporate Authors' : 'Corporate Author';
+                },
+                'context' => ['type' => 'corporate', 'schemaLabel' => 'creator'],
+            ]
+        );
+        $spec->setTemplateLine(
+            'Other Authors', 'getDeduplicatedAuthors', 'data-authors.phtml',
+            [
+                'useCache' => true,
+                'context' => [
+                    'type' => 'secondary', 'schemaLabel' => 'contributor'
+                ],
+            ]
+        );
+        $spec->setLine(
+            'Format', 'getFormats', 'RecordHelper',
+            ['helperMethod' => 'getFormatList']
+        );
+        $spec->setLine('Language', 'getLanguages');
+        $spec->setTemplateLine(
+            'Published', 'getPublicationDetails', 'data-publicationDetails.phtml'
+        );
+        $spec->setLine(
+            'Edition', 'getEdition', null,
+            ['prefix' => '<span property="bookEdition">', 'suffix' => '</span>']
+        );
+        $spec->setTemplateLine('Series', 'getSeries', 'data-series.phtml');
+        $spec->setTemplateLine(
+            'Subjects', 'getAllSubjectHeadings', 'data-allSubjectHeadings.phtml'
+        );
+        $spec->setTemplateLine(
+            'child_records', 'getChildRecordCount', 'data-childRecords.phtml',
+            ['allowZero' => false]
+        );
+        $spec->setTemplateLine('Online Access', true, 'data-onlineAccess.phtml');
+        $spec->setTemplateLine(
+            'Related Items', 'getAllRecordLinks', 'data-allRecordLinks.phtml'
+        );
+        $spec->setTemplateLine('Tags', true, 'data-tags.phtml');
+        return $spec->getArray();
+    }
+
+    /**
+     * Get default specifications for displaying data in the description tab.
+     *
+     * @return array
+     */
+    public function getDefaultDescriptionSpecs()
+    {
+        $spec = new RecordDataFormatter\SpecBuilder();
+        $spec->setLine('Summary', 'getSummary');
+        $spec->setLine('Published', 'getDateSpan');
+        $spec->setLine('Item Description', 'getGeneralNotes');
+        $spec->setLine('Physical Description', 'getPhysicalDescriptions');
+        $spec->setLine('Publication Frequency', 'getPublicationFrequency');
+        $spec->setLine('Playing Time', 'getPlayingTimes');
+        $spec->setLine('Format', 'getSystemDetails');
+        $spec->setLine('Audience', 'getTargetAudienceNotes');
+        $spec->setLine('Awards', 'getAwards');
+        $spec->setLine('Production Credits', 'getProductionCredits');
+        $spec->setLine('Bibliography', 'getBibliographyNotes');
+        $spec->setLine('ISBN', 'getISBNs');
+        $spec->setLine('ISSN', 'getISSNs');
+        $spec->setLine('DOI', 'getCleanDOI');
+        $spec->setLine('Related Items', 'getRelationshipNotes');
+        $spec->setLine('Access', 'getAccessRestrictions');
+        $spec->setLine('Finding Aid', 'getFindingAids');
+        $spec->setLine('Publication_Place', 'getHierarchicalPlaceNames');
+        $spec->setTemplateLine('Author Notes', true, 'data-authorNotes.phtml');
+        return $spec->getArray();
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatter/SpecBuilderTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatter/SpecBuilderTest.php
new file mode 100644
index 00000000000..4466b8fd6db
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatter/SpecBuilderTest.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * RecordDataFormatter spec builder Test Class
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\View\Helper\Root\RecordDataFormatter;
+use VuFind\View\Helper\Root\RecordDataFormatter\SpecBuilder;
+
+/**
+ * RecordDataFormatter spec builder Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class SpecBuilderTest extends \VuFindTest\Unit\ViewHelperTestCase
+{
+    /**
+     * Test the spec builder
+     *
+     * @return void
+     */
+    public function testBuilder()
+    {
+        $builder = new SpecBuilder();
+        $builder->setLine('foo', 'getFoo');
+        $builder->setLine('bar', 'getBar');
+        $builder->setTemplateLine('xyzzy', 'getXyzzy', 'xyzzy.phtml');
+        $expected = [
+            'foo' => [
+                'dataMethod' => 'getFoo',
+                'renderType' => null,
+                'pos' => 100,
+            ],
+            'bar' => [
+                'dataMethod' => 'getBar',
+                'renderType' => null,
+                'pos' => 200,
+            ],
+            'xyzzy' => [
+                'template' => 'xyzzy.phtml',
+                'dataMethod' => 'getXyzzy',
+                'renderType' => 'RecordDriverTemplate',
+                'pos' => 300,
+            ],
+        ];
+        $this->assertEquals($expected, $builder->getArray());
+        $builder->reorderKeys(['xyzzy', 'bar']);
+        $expected['xyzzy']['pos'] = 100;
+        $expected['bar']['pos'] = 200;
+        $expected['foo']['pos'] = 300;
+        $this->assertEquals($expected, $builder->getArray());
+        $builder->reorderKeys(['xyzzy', 'bar'], 0);
+        $expected['foo']['pos'] = 0;
+        $this->assertEquals($expected, $builder->getArray());
+        $builder->reorderKeys(['foo', 'baz', 'xyzzy', 'bar']);
+        $expected['xyzzy']['pos'] = 300;
+        $expected['bar']['pos'] = 400;
+        $expected['foo']['pos'] = 100;
+        $this->assertEquals($expected, $builder->getArray());
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
new file mode 100644
index 00000000000..4d09734a348
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/RecordDataFormatterTest.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * RecordDataFormatter Test Class
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\View\Helper\Root;
+use VuFind\View\Helper\Root\RecordDataFormatter;
+use VuFind\View\Helper\Root\RecordDataFormatterFactory;
+
+/**
+ * RecordDataFormatter Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class RecordDataFormatterTest extends \VuFindTest\Unit\ViewHelperTestCase
+{
+    /**
+     * Setup test case.
+     *
+     * Mark test skipped if short_open_tag is not enabled. The partial
+     * uses short open tags. This directive is PHP_INI_PERDIR,
+     * i.e. can only be changed via php.ini or a per-directory
+     * equivalent. The test will fail if the test is run on
+     * a system with short_open_tag disabled in the system-wide php
+     * ini-file.
+     *
+     * @return void
+     */
+    protected function setup()
+    {
+        parent::setup();
+        if (!ini_get('short_open_tag')) {
+            $this->markTestSkipped('Test requires short_open_tag to be enabled');
+        }
+    }
+
+    /**
+     * Get view helpers needed by test.
+     *
+     * @return array
+     */
+    protected function getViewHelpers()
+    {
+        $context = new \VuFind\View\Helper\Root\Context();
+        return [
+            'auth' => new \VuFind\View\Helper\Root\Auth($this->getMockBuilder('VuFind\Auth\Manager')->disableOriginalConstructor()->getMock()),
+            'context' => $context,
+            'openUrl' => new \VuFind\View\Helper\Root\OpenUrl($context, []),
+            'proxyUrl' => new \VuFind\View\Helper\Root\ProxyUrl(),
+            'record' => new \VuFind\View\Helper\Root\Record(),
+            'recordLink' => new \VuFind\View\Helper\Root\RecordLink($this->getMockBuilder('VuFind\Record\Router')->disableOriginalConstructor()->getMock()),
+            'searchTabs' => $this->getMockBuilder('VuFind\View\Helper\Root\SearchTabs')->disableOriginalConstructor()->getMock(),
+            'transEsc' => new \VuFind\View\Helper\Root\TransEsc(),
+            'translate' => new \VuFind\View\Helper\Root\Translate(),
+            'usertags' => new \VuFind\View\Helper\Root\UserTags(),
+        ];
+    }
+
+    /**
+     * Get a record driver with fake data.
+     *
+     * @param array $overrides Fixture fields to override.
+     *
+     * @return SolrDefault
+     */
+    protected function getDriver($overrides = [])
+    {
+        // "Mock out" tag functionality to avoid database access:
+        $record = $this->getMockBuilder('VuFind\RecordDriver\SolrDefault')
+            ->setMethods(['getBuilding', 'getContainerTitle', 'getTags'])
+            ->getMock();
+        $record->expects($this->any())->method('getTags')
+            ->will($this->returnValue([]));
+        // Force a return value of zero so we can test this edge case value (even
+        // though in the context of "building"/"container title" it makes no sense):
+        $record->expects($this->any())->method('getBuilding')
+            ->will($this->returnValue(0));
+        $record->expects($this->any())->method('getContainerTitle')
+            ->will($this->returnValue('0'));
+
+        // Load record data from fixture file:
+        $fixture = json_decode(
+            file_get_contents(
+                realpath(
+                    VUFIND_PHPUNIT_MODULE_PATH . '/fixtures/misc/testbug2.json'
+                )
+            ),
+            true
+        );
+        $record->setRawData($overrides + $fixture['response']['docs'][0]);
+        return $record;
+    }
+
+    /**
+     * Build a formatter, including necessary mock view w/ helpers.
+     *
+     * @return RecordDataFormatter
+     */
+    protected function getFormatter()
+    {
+        // Build the formatter:
+        $factory = new RecordDataFormatterFactory();
+        $formatter = $factory->__invoke();
+
+        // Create a view object with a set of helpers:
+        $helpers = $this->getViewHelpers();
+        $view = $this->getPhpRenderer($helpers);
+
+        // Mock out the router to avoid errors:
+        $match = new \Zend\Mvc\Router\RouteMatch([]);
+        $match->setMatchedRouteName('foo');
+        $view->plugin('url')
+            ->setRouter($this->getMock('Zend\Mvc\Router\RouteStackInterface'))
+            ->setRouteMatch($match);
+
+        // Inject the view object into all of the helpers:
+        $formatter->setView($view);
+        foreach ($helpers as $helper) {
+            $helper->setView($view);
+        }
+
+        return $formatter;
+    }
+
+    /**
+     * Test citation generation
+     *
+     * @return void
+     */
+    public function testFormatting()
+    {
+        $formatter = $this->getFormatter();
+        $spec = $formatter->getDefaults('core');
+        $spec['Building'] = ['dataMethod' => 'getBuilding', 'pos' => 0];
+
+        $expected = [
+            'Building' => '0',
+            'Published in' => '0',
+            'Main Author' => 'Vico, Giambattista, 1668-1744.',
+            'Other Authors' => 'Pandolfi, Claudia.',
+            'Format' => 'Book',
+            'Language' => 'ItalianLatin',
+            'Published' => 'Centro di Studi Vichiani, 1992',
+            'Edition' => 'Fictional edition.',
+            'Series' => 'Vico, Giambattista, 1668-1744. Works. 1982 ;',
+            'Subjects' => 'Naples (Kingdom) History Spanish rule, 1442-1707 Sources',
+            'Online Access' => 'http://fictional.com/sample/url',
+            'Tags' => 'Add Tag No Tags, Be the first to tag this record!',
+        ];
+        $driver = $this->getDriver();
+        $results = $formatter->getData($driver, $spec);
+
+        // Check for expected array keys
+        $this->assertEquals(array_keys($expected), array_keys($results));
+
+        // Check for expected text (with markup stripped)
+        foreach ($expected as $key => $value) {
+            $this->assertEquals(
+                $value, trim(preg_replace('/\s+/', ' ', strip_tags($results[$key])))
+            );
+        }
+
+        // Check for exact markup in representative example:
+        $this->assertEquals('Italian<br />Latin', $results['Language']);
+    }
+}
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/core.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/core.phtml
index 0d411fb7425..4504c410944 100644
--- a/themes/bootstrap3/templates/RecordDriver/SolrDefault/core.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/core.phtml
@@ -1,22 +1,3 @@
-<?
-  if($loggedin = $this->auth()->isLoggedIn()) {
-    $user_id = $loggedin->id;
-    $loggedin = true;
-  } else {
-    $user_id = false;
-  }
-
-  $formatRoles = function ($roles) {
-    if (count($roles) == 0) {
-      return '';
-    }
-    $that = $this;
-    $translate = function ($str) use ($that) {
-      return $that->transEsc('CreatorRoles::' . $str);
-    };
-    return ' (' . implode(', ', array_unique(array_map($translate, $roles))) . ')';
-  };
-?>
 <div class="row" vocab="http://schema.org/" resource="#record" typeof="<?=$this->driver->getSchemaOrgFormats()?> Product">
   <? $QRCode = $this->record($this->driver)->getQRCode("core");
      $cover = $this->record($this->driver)->getCover('core', 'medium', $this->record($this->driver)->getThumbnail('large'));
@@ -68,221 +49,17 @@
     <? endif; ?>
 
     <?/* Display Main Details */?>
-    <table class="table table-striped" summary="<?=$this->transEsc('Bibliographic Details')?>">
-      <? $journalTitle = $this->driver->getContainerTitle(); if (!empty($journalTitle)): ?>
-      <tr>
-        <th><?=$this->transEsc('Published in')?>:</th>
-        <td>
-          <?
-            $containerSource = $this->driver->getSourceIdentifier();
-            $containerID = $this->driver->getContainerRecordID();
-          ?>
-          <a href="<?=($containerID ? $this->recordLink()->getUrl("$containerSource|$containerID") : $this->record($this->driver)->getLink('journaltitle', $journalTitle))?>"><?=$this->escapeHtml($journalTitle)?></a>
-          <? $ref = $this->driver->getContainerReference(); if (!empty($ref)) { echo $this->escapeHtml($ref); } ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $nextTitles = $this->driver->getNewerTitles(); $prevTitles = $this->driver->getPreviousTitles(); ?>
-      <? if (!empty($nextTitles)): ?>
-      <tr>
-        <th><?=$this->transEsc('New Title')?>: </th>
-        <td>
-          <? foreach($nextTitles as $field): ?>
-            <a href="<?=$this->record($this->driver)->getLink('title', $field)?>"><?=$this->escapeHtml($field)?></a><br/>
-          <? endforeach; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? if (!empty($prevTitles)): ?>
-      <tr>
-        <th><?=$this->transEsc('Previous Title')?>: </th>
-        <td>
-          <? foreach($prevTitles as $field): ?>
-            <a href="<?=$this->record($this->driver)->getLink('title', $field)?>"><?=$this->escapeHtml($field)?></a><br/>
-          <? endforeach; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $authors = $this->driver->getDeduplicatedAuthors(); ?>
-      <? if (isset($authors['main']) && !empty($authors['main'])): ?>
-        <tr>
-          <th><?=$this->transEsc(count($authors['main']) > 1 ? 'Main Authors' : 'Main Author')?>: </th>
-          <td>
-            <? $i = 0; foreach ($authors['main'] as $author => $roles): ?><?=($i++ == 0)?'':', '?><span property="author"><a href="<?=$this->record($this->driver)->getLink('author', $author)?>"><?=$this->escapeHtml($author)?></a><?=$formatRoles($roles)?></span><? endforeach; ?>
-          </td>
-        </tr>
-      <? endif; ?>
-
-      <? if (isset($authors['corporate']) && !empty($authors['corporate'])): ?>
-        <tr>
-          <th><?=$this->transEsc(count($authors['corporate']) > 1 ? 'Corporate Author' : 'Corporate Authors')?>: </th>
-          <td>
-            <? $i = 0; foreach ($authors['corporate'] as $corporate => $roles): ?><?=($i++ == 0)?'':', '?><span property="creator"><a href="<?=$this->record($this->driver)->getLink('author', $corporate)?>"><?=$this->escapeHtml($corporate)?></a><?=$formatRoles($roles)?></span><? endforeach; ?>
-          </td>
-        </tr>
-      <? endif; ?>
-
-      <? if (isset($authors['secondary']) && !empty($authors['secondary'])): ?>
-        <tr>
-          <th><?=$this->transEsc('Other Authors')?>: </th>
-          <td>
-            <? $i = 0; foreach ($authors['secondary'] as $author => $roles): ?><?=($i++ == 0)?'':', '?><span property="contributor"><a href="<?=$this->record($this->driver)->getLink('author', $author)?>"><?=$this->escapeHtml($author)?></a><?=$formatRoles($roles)?></span><? endforeach; ?>
-          </td>
-        </tr>
-      <? endif; ?>
-
-      <? if (count($this->driver->getFormats()) > 0): ?>
-        <tr>
-          <th><?=$this->transEsc('Format')?>: </th>
-          <td><?=$this->record($this->driver)->getFormatList()?></td>
-        </tr>
-      <? endif; ?>
-
-      <? $langs = $this->driver->getLanguages(); if (!empty($langs)): ?>
-        <tr>
-          <th><?=$this->transEsc('Language')?>: </th>
-          <td><? foreach ($langs as $lang): ?><?= $this->escapeHtml($lang)?><br/><? endforeach; ?></td>
-        </tr>
-      <? endif; ?>
-
-      <? $publications = $this->driver->getPublicationDetails(); if (!empty($publications)): ?>
-      <tr>
-        <th><?=$this->transEsc('Published')?>: </th>
-        <td>
-          <? foreach ($publications as $field): ?>
-            <span property="publisher" typeof="Organization">
-            <? $pubPlace = $field->getPlace(); if (!empty($pubPlace)): ?>
-              <span property="location"><?=$this->escapeHtml($pubPlace)?></span>
-            <? endif; ?>
-            <? $pubName = $field->getName(); if (!empty($pubName)): ?>
-              <span property="name"><?=$this->escapeHtml($pubName)?></span>
-            <? endif; ?>
-            </span>
-            <? $pubDate = $field->getDate(); if (!empty($pubDate)): ?>
-              <span property="publicationDate"><?=$this->escapeHtml($pubDate)?></span>
-            <? endif; ?>
-            <br/>
-          <? endforeach; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $edition = $this->driver->getEdition(); if (!empty($edition)): ?>
-      <tr>
-        <th><?=$this->transEsc('Edition')?>: </th>
-        <td property="bookEdition"><?=$this->escapeHtml($edition)?></td>
-      </tr>
-      <? endif; ?>
-
-      <?/* Display series section if at least one series exists. */?>
-      <? $series = $this->driver->getSeries(); if (!empty($series)): ?>
-      <tr>
-        <th><?=$this->transEsc('Series')?>: </th>
-        <td>
-          <? foreach ($series as $field): ?>
-            <?/* Depending on the record driver, $field may either be an array with
-               "name" and "number" keys or a flat string containing only the series
-               name.  We should account for both cases to maximize compatibility. */?>
-            <? if (is_array($field)): ?>
-              <? if (!empty($field['name'])): ?>
-                <a href="<?=$this->record($this->driver)->getLink('series', $field['name'])?>"><?=$this->escapeHtml($field['name'])?></a>
-                <? if (!empty($field['number'])): ?>
-                  <?=$this->escapeHtml($field['number'])?>
-                <? endif; ?>
-                <br/>
-              <? endif; ?>
-            <? else: ?>
-              <a href="<?=$this->record($this->driver)->getLink('series', $field)?>"><?=$this->escapeHtml($field)?></a><br/>
-            <? endif; ?>
-          <? endforeach; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $subjects = $this->driver->getAllSubjectHeadings(); if (!empty($subjects)): ?>
-      <tr>
-        <th><?=$this->transEsc('Subjects')?>: </th>
-        <td>
-          <? foreach ($subjects as $field): ?>
-          <div class="subject-line" property="keywords">
-            <? $subject = ''; ?>
-            <? if(count($field) == 1) $field = explode('--', $field[0]); ?>
-            <? $i = 0; foreach ($field as $subfield): ?>
-              <?=($i++ == 0) ? '' : ' &gt; '?>
-              <? $subject = trim($subject . ' ' . $subfield); ?>
-              <a title="<?=$this->escapeHtmlAttr($subject)?>" href="<?=$this->record($this->driver)->getLink('subject', $subject)?>" rel="nofollow"><?=trim($this->escapeHtml($subfield))?></a>
-            <? endforeach; ?>
-          </div>
-          <? endforeach; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $childRecordCount = $this->driver->tryMethod('getChildRecordCount'); if ($childRecordCount): ?>
-      <tr>
-        <th><?=$this->transEsc('child_records')?>: </th>
-        <td>
-          <a href="<?=$this->recordLink()->getChildRecordSearchUrl($this->driver)?>"><?=$this->transEsc('child_record_count', array('%%count%%' => $childRecordCount))?></a>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <?
-        $openUrl = $this->openUrl($this->driver, 'record');
-        $openUrlActive = $openUrl->isActive();
-        // Account for replace_other_urls setting
-        $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
-      ?>
-      <? if (!empty($urls) || $openUrlActive): ?>
-      <tr>
-        <th><?=$this->transEsc('Online Access')?>: </th>
-        <td>
-          <? foreach ($urls as $current): ?>
-            <a href="<?=$this->escapeHtmlAttr($this->proxyUrl($current['url']))?>"><?=$this->escapeHtml($current['desc'])?></a><br/>
-          <? endforeach; ?>
-          <? if ($openUrlActive): ?>
-            <?=$openUrl->renderTemplate()?><br/>
-          <? endif; ?>
-        </td>
-      </tr>
-      <? endif; ?>
-
-      <? $recordLinks = $this->driver->getAllRecordLinks(); ?>
-      <? if(!empty($recordLinks)): ?>
-        <tr>
-          <th><?=$this->transEsc('Related Items')?>:</th>
-          <td>
-            <? foreach ($recordLinks as $recordLink): ?>
-              <?=$this->transEsc($recordLink['title'])?>:
-              <a href="<?=$this->recordLink()->related($recordLink['link'])?>"><?=$this->escapeHtml($recordLink['value'])?></a><br />
-            <? endforeach; ?>
-            <? /* if we have record links, display relevant explanatory notes */
-              $related = $this->driver->getRelationshipNotes();
-              if (!empty($related)): ?>
-                <? foreach ($related as $field): ?>
-                  <?=$this->escapeHtml($field)?><br/>
-                <? endforeach; ?>
-            <? endif; ?>
-          </td>
-        </tr>
-      <? endif; ?>
-
-      <? if ($this->usertags()->getMode() !== 'disabled'): ?>
-        <? $tagList = $this->driver->getTags(null, null, 'count', $user_id); ?>
-        <tr>
-          <th><?=$this->transEsc('Tags')?>: </th>
-          <td>
-            <a class="tag-record btn btn-link pull-right flip" href="<?=$this->recordLink()->getActionUrl($this->driver, 'AddTag')?>" data-lightbox>
-              <i class="fa fa-plus" aria-hidden="true"></i> <?=$this->transEsc('Add Tag')?>
-            </a>
-            <?=$this->context($this)->renderInContext('record/taglist', array('tagList'=>$tagList, 'loggedin'=>$loggedin)) ?>
-          </td>
-        </tr>
-      <? endif; ?>
-    </table>
+    <?
+      $formatter = $this->recordDataFormatter();
+      $coreFields = $formatter->getData($driver, $formatter->getDefaults('core'));
+    ?>
+    <? if (!empty($coreFields)): ?>
+      <table class="table table-striped" summary="<?=$this->transEsc('Bibliographic Details')?>">
+        <? foreach ($coreFields as $key => $value): ?>
+          <tr><th><?=$this->transEsc($key)?>:</th><td><?=$value?></td></tr>
+        <? endforeach; ?>
+      </table>
+    <? endif; ?>
     <?/* End Main Details */?>
   </div>
 </div>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allRecordLinks.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allRecordLinks.phtml
new file mode 100644
index 00000000000..f2a8dfd4007
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allRecordLinks.phtml
@@ -0,0 +1,11 @@
+<? foreach ($data as $recordLink): ?>
+  <?=$this->transEsc($recordLink['title'])?>:
+  <a href="<?=$this->recordLink()->related($recordLink['link'])?>"><?=$this->escapeHtml($recordLink['value'])?></a><br />
+<? endforeach; ?>
+<? /* if we have record links, display relevant explanatory notes */
+  $related = $this->driver->getRelationshipNotes();
+  if (!empty($related)): ?>
+    <? foreach ($related as $field): ?>
+      <?=$this->escapeHtml($field)?><br/>
+    <? endforeach; ?>
+<? endif; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allSubjectHeadings.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allSubjectHeadings.phtml
new file mode 100644
index 00000000000..2ab86fa7b88
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-allSubjectHeadings.phtml
@@ -0,0 +1,11 @@
+<? foreach ($data as $field): ?>
+  <div class="subject-line" property="keywords">
+    <? $subject = ''; ?>
+    <? if(count($field) == 1) $field = explode('--', $field[0]); ?>
+    <? $i = 0; foreach ($field as $subfield): ?>
+      <?=($i++ == 0) ? '' : ' &gt; '?>
+      <? $subject = trim($subject . ' ' . $subfield); ?>
+      <a title="<?=$this->escapeHtmlAttr($subject)?>" href="<?=$this->record($this->driver)->getLink('subject', $subject)?>" rel="nofollow"><?=trim($this->escapeHtml($subfield))?></a>
+    <? endforeach; ?>
+  </div>
+<? endforeach; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authorNotes.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authorNotes.phtml
new file mode 100644
index 00000000000..1d58594cea1
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authorNotes.phtml
@@ -0,0 +1,8 @@
+<? $isbn = $this->driver->getCleanISBN(); ?>
+<? $authorNotes = empty($isbn) ? [] : $this->authorNotes($isbn); if (!empty($authorNotes)): ?>
+  <? foreach ($authorNotes as $provider => $list): ?>
+    <? foreach ($list as $field): ?>
+      <?=$field['Content']?><br />
+    <? endforeach; ?>
+  <? endforeach; ?>
+<? endif; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authors.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authors.phtml
new file mode 100644
index 00000000000..c63f6c6274e
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-authors.phtml
@@ -0,0 +1,15 @@
+<?
+  $formatRoles = function ($roles) {
+    if (count($roles) == 0) {
+      return '';
+    }
+    $that = $this;
+    $translate = function ($str) use ($that) {
+      return $that->transEsc('CreatorRoles::' . $str);
+    };
+    return ' (' . implode(', ', array_unique(array_map($translate, $roles))) . ')';
+  };
+?>
+<? if (isset($data[$type]) && !empty($data[$type])): ?>
+  <? $i = 0; foreach ($data[$type] as $author => $roles): ?><?=($i++ == 0)?'':', '?><span property="<?=$this->escapeHtml($schemaLabel)?>"><a href="<?=$this->record($this->driver)->getLink('author', $author)?>"><?=$this->escapeHtml($author)?></a><?=$formatRoles($roles)?></span><? endforeach; ?>
+<? endif; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-childRecords.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-childRecords.phtml
new file mode 100644
index 00000000000..bcd2de76312
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-childRecords.phtml
@@ -0,0 +1 @@
+<a href="<?=$this->recordLink()->getChildRecordSearchUrl($this->driver)?>"><?=$this->transEsc('child_record_count', array('%%count%%' => $data))?></a>
\ No newline at end of file
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-containerTitle.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-containerTitle.phtml
new file mode 100644
index 00000000000..82cba51d8c6
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-containerTitle.phtml
@@ -0,0 +1,10 @@
+<?
+  $containerSource = $this->driver->getSourceIdentifier();
+  $containerID = $this->driver->getContainerRecordID();
+  $ref = $this->driver->getContainerReference();
+  $link = $containerID
+    ? $this->recordLink()->getUrl("$containerSource|$containerID")
+    : $this->record($this->driver)->getLink('journaltitle', $data);
+?>
+<a href="<?=$link?>"><?=$this->escapeHtml($data)?></a>
+<?=empty($ref) ? '' : $this->escapeHtml($ref)?>
\ No newline at end of file
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-onlineAccess.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-onlineAccess.phtml
new file mode 100644
index 00000000000..2ddb8a1bddf
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-onlineAccess.phtml
@@ -0,0 +1,14 @@
+<?
+  $openUrl = $this->openUrl($this->driver, 'record');
+  $openUrlActive = $openUrl->isActive();
+  // Account for replace_other_urls setting
+  $urls = $this->record($this->driver)->getLinkDetails($openUrlActive);
+?>
+<? if (!empty($urls) || $openUrlActive): ?>
+  <? foreach ($urls as $current): ?>
+    <a href="<?=$this->escapeHtmlAttr($this->proxyUrl($current['url']))?>"><?=$this->escapeHtml($current['desc'])?></a><br/>
+  <? endforeach; ?>
+  <? if ($openUrlActive): ?>
+    <?=$openUrl->renderTemplate()?><br/>
+  <? endif; ?>
+<? endif; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-publicationDetails.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-publicationDetails.phtml
new file mode 100644
index 00000000000..c8b7a78a2eb
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-publicationDetails.phtml
@@ -0,0 +1,14 @@
+<? foreach ($data as $field): ?>
+  <span property="publisher" typeof="Organization">
+  <? $pubPlace = $field->getPlace(); if (!empty($pubPlace)): ?>
+    <span property="location"><?=$this->escapeHtml($pubPlace)?></span>
+  <? endif; ?>
+  <? $pubName = $field->getName(); if (!empty($pubName)): ?>
+    <span property="name"><?=$this->escapeHtml($pubName)?></span>
+  <? endif; ?>
+  </span>
+  <? $pubDate = $field->getDate(); if (!empty($pubDate)): ?>
+    <span property="publicationDate"><?=$this->escapeHtml($pubDate)?></span>
+  <? endif; ?>
+  <br/>
+<? endforeach; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-series.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-series.phtml
new file mode 100644
index 00000000000..b71c03ebc2f
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-series.phtml
@@ -0,0 +1,16 @@
+<? foreach ($data as $field): ?>
+  <?/* Depending on the record driver, $field may either be an array with
+     "name" and "number" keys or a flat string containing only the series
+     name.  We should account for both cases to maximize compatibility. */?>
+  <? if (is_array($field)): ?>
+    <? if (!empty($field['name'])): ?>
+      <a href="<?=$this->record($this->driver)->getLink('series', $field['name'])?>"><?=$this->escapeHtml($field['name'])?></a>
+      <? if (!empty($field['number'])): ?>
+        <?=$this->escapeHtml($field['number'])?>
+      <? endif; ?>
+      <br/>
+    <? endif; ?>
+  <? else: ?>
+    <a href="<?=$this->record($this->driver)->getLink('series', $field)?>"><?=$this->escapeHtml($field)?></a><br/>
+  <? endif; ?>
+<? endforeach; ?>
diff --git a/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-tags.phtml b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-tags.phtml
new file mode 100644
index 00000000000..285e4ea0d31
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/SolrDefault/data-tags.phtml
@@ -0,0 +1,15 @@
+<?
+  if($loggedin = $this->auth()->isLoggedIn()) {
+    $user_id = $loggedin->id;
+    $loggedin = true;
+  } else {
+    $user_id = false;
+  }
+?>
+<? if ($this->usertags()->getMode() !== 'disabled'): ?>
+  <? $tagList = $this->driver->getTags(null, null, 'count', $user_id); ?>
+    <a class="tag-record btn btn-link pull-right flip" href="<?=$this->recordLink()->getActionUrl($this->driver, 'AddTag')?>" data-lightbox>
+      <i class="fa fa-plus" aria-hidden="true"></i> <?=$this->transEsc('Add Tag')?>
+    </a>
+    <?=$this->context($this)->renderInContext('record/taglist', array('tagList'=>$tagList, 'loggedin'=>$loggedin)) ?>
+<? endif; ?>
diff --git a/themes/bootstrap3/templates/RecordTab/description.phtml b/themes/bootstrap3/templates/RecordTab/description.phtml
index d1794847e4f..e989574ff21 100644
--- a/themes/bootstrap3/templates/RecordTab/description.phtml
+++ b/themes/bootstrap3/templates/RecordTab/description.phtml
@@ -2,237 +2,15 @@
     // Set page title.
     $this->headTitle($this->translate('Description') . ': ' . $this->driver->getBreadcrumb());
 
-    // Grab clean ISBN for convenience:
-    $isbn = $this->driver->getCleanISBN();
+    $formatter = $this->recordDataFormatter();
+    $mainFields = $formatter->getData($driver, $formatter->getDefaults('description'));
 ?>
 <table class="table table-striped" summary="<?=$this->transEsc('Description')?>">
-  <? $summ = $this->driver->getSummary(); if (!empty($summ)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Summary')?>: </th>
-      <td>
-        <? foreach ($summ as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $dateSpan = $this->driver->getDateSpan(); if (!empty($dateSpan)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Published')?>: </th>
-      <td>
-        <? foreach ($dateSpan as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $notes = $this->driver->getGeneralNotes(); if (!empty($notes)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Item Description')?>: </th>
-      <td>
-        <? foreach ($notes as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $physical = $this->driver->getPhysicalDescriptions(); if (!empty($physical)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Physical Description')?>: </th>
-      <td>
-        <? foreach ($physical as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $freq = $this->driver->getPublicationFrequency(); if (!empty($freq)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Publication Frequency')?>: </th>
-      <td>
-        <? foreach ($freq as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $playTime = $this->driver->getPlayingTimes(); if (!empty($playTime)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Playing Time')?>: </th>
-      <td>
-        <? foreach ($playTime as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $system = $this->driver->getSystemDetails(); if (!empty($system)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Format')?>: </th>
-      <td>
-        <? foreach ($system as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $audience = $this->driver->getTargetAudienceNotes(); if (!empty($audience)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Audience')?>: </th>
-      <td>
-        <? foreach ($audience as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $awards = $this->driver->getAwards(); if (!empty($awards)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Awards')?>: </th>
-      <td>
-        <? foreach ($awards as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $credits = $this->driver->getProductionCredits(); if (!empty($credits)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Production Credits')?>: </th>
-      <td>
-        <? foreach ($credits as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $bib = $this->driver->getBibliographyNotes(); if (!empty($bib)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Bibliography')?>: </th>
-      <td>
-        <? foreach ($bib as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $isbns = $this->driver->getISBNs(); if (!empty($isbns)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('ISBN')?>: </th>
-      <td>
-        <? foreach ($isbns as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $issns = $this->driver->getISSNs(); if (!empty($issns)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('ISSN')?>: </th>
-      <td>
-        <? foreach ($issns as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $doi = $this->driver->tryMethod('getCleanDOI'); if (!empty($doi)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('DOI')?>: </th>
-      <td><?=$this->escapeHtml($doi)?></td>
-    </tr>
-  <? endif; ?>
-
-  <? $related = $this->driver->getRelationshipNotes(); if (!empty($related)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Related Items')?>: </th>
-      <td>
-        <? foreach ($related as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $access = $this->driver->getAccessRestrictions(); if (!empty($access)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Access')?>: </th>
-      <td>
-        <? foreach ($access as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $findingAids = $this->driver->getFindingAids(); if (!empty($findingAids)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Finding Aid')?>: </th>
-      <td>
-        <? foreach ($findingAids as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $publicationPlaces = $this->driver->getHierarchicalPlaceNames(); if (!empty($publicationPlaces)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Publication_Place')?>: </th>
-      <td>
-        <? foreach ($publicationPlaces as $field): ?>
-          <?=$this->escapeHtml($field)?><br/>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? $authorNotes = empty($isbn) ? [] : $this->authorNotes($isbn); if (!empty($authorNotes)): ?>
-    <? $contentDisplayed = true; ?>
-    <tr>
-      <th><?=$this->transEsc('Author Notes')?>: </th>
-      <td>
-        <? foreach ($authorNotes as $provider => $list): ?>
-          <? foreach ($list as $field): ?>
-            <?=$field['Content']?><br/>
-          <? endforeach; ?>
-        <? endforeach; ?>
-      </td>
-    </tr>
-  <? endif; ?>
-
-  <? if (!isset($contentDisplayed) || !$contentDisplayed): // Avoid errors if there were no rows above ?>
+  <? if (!empty($mainFields)): ?>
+    <? foreach ($mainFields as $key => $value): ?>
+      <tr><th><?=$this->transEsc($key)?>:</th><td><?=$value?></td></tr>
+    <? endforeach; ?>
+  <? else: ?>
     <tr><td><?=$this->transEsc('no_description')?></td></tr>
   <? endif; ?>
 </table>
diff --git a/themes/root/theme.config.php b/themes/root/theme.config.php
index 0de80f7ed5c..a6d343b3517 100644
--- a/themes/root/theme.config.php
+++ b/themes/root/theme.config.php
@@ -27,6 +27,7 @@ return array(
             'piwik' => 'VuFind\View\Helper\Root\Factory::getPiwik',
             'recaptcha' => 'VuFind\View\Helper\Root\Factory::getRecaptcha',
             'record' => 'VuFind\View\Helper\Root\Factory::getRecord',
+            'recorddataformatter' => 'VuFind\View\Helper\Root\RecordDataFormatterFactory',
             'recordlink' => 'VuFind\View\Helper\Root\Factory::getRecordLink',
             'related' => 'VuFind\View\Helper\Root\Factory::getRelated',
             'safemoneyformat' => 'VuFind\View\Helper\Root\Factory::getSafeMoneyFormat',
-- 
GitLab