<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Controllers\Admin;
use Exception;
use InvalidArgumentException;
use Sprout\Controllers\Controller;
use karmabunny\pdb\Exceptions\ConstraintQueryException;
use Sprout\Exceptions\FileMissingException;
use karmabunny\pdb\Exceptions\RowMissingException;
use Sprout\Helpers\AdminAuth;
use Sprout\Helpers\AdminError;
use Sprout\Helpers\AdminPerms;
use Sprout\Helpers\Constants;
use Sprout\Helpers\Csrf;
use Sprout\Helpers\Enc;
use Sprout\Helpers\ImportCSV;
use Sprout\Helpers\Inflector;
use Sprout\Helpers\Itemlist;
use Sprout\Helpers\Json;
use Sprout\Helpers\JsonForm;
use Sprout\Helpers\Notification;
use Sprout\Helpers\Pdb;
use Sprout\Helpers\PerRecordPerms;
use Sprout\Helpers\QueryTo;
use Sprout\Helpers\RefineBar;
use Sprout\Helpers\RefineWidgetSelect;
use Sprout\Helpers\RefineWidgetTextbox;
use Sprout\Helpers\Session;
use Sprout\Helpers\Tags;
use Sprout\Helpers\Url;
use Sprout\Helpers\Validator;
use Sprout\Helpers\View;
/**
* This is a generic controller which all controllers which are managed in the admin area should extend.
*
* Required fields for a managed controller table:
* id
*
* @tag api
* @tag module-api
**/
abstract class ManagedAdminController extends Controller {
/**
* This is the name of the controller - should match the class name, but without the '_Controller' bit.
**/
protected $controller_name;
/**
* This is the friendly name of the controller. In 99% of cases, should be the plural form of the controller name
**/
protected $friendly_name;
/**
* The friendly name used in the sidebar navigation. Defaults to matching the friendly name.
**/
protected $navigation_name;
/**
* This is the name of the table to get data from. Will be automatically deducted from the controller name if not specified
**/
protected $table_name;
/**
* Default values used for adding a record.
**/
protected $add_defaults;
/**
* Default values used for duplicating a record.
**/
protected $duplicate_defaults = array( 'name' => '',
);
/**
* The columns to use for the main view
**/
protected $main_columns;
/**
* Order of main view records
**/
protected $main_order = 'item.name';
/**
* An additional where clause for the main view
**/
protected $main_where = array();
/**
* Actions for the itemlist
**/
protected $main_actions = array();
/**
* Should a link be shown above the list for adding records? (default yes)
**/
protected $main_add = true;
/** Is deletion allowed, with an option shown in the UI? (default: no) */
protected $main_delete = false;
/**
* Different modes available for the main view
* By default, there is only one mode: list
**/
protected $main_modes = array();
/**
* The columns to allow import for
**/
protected $import_columns;
/**
* The default selection for the "duplicates" option
* Values are "new", "merge", "merge_blank" and "skip".
**/
protected $import_duplicates = '';
/**
* Typically, we don't want to import the ID, and just let autoinc do it's thing
**/
protected $import_id_column = false;
/**
* If a client is providing CSVs which don't have headings
* You'll need to provide them in this array
**/
protected $import_headings = null;
/**
* Modifiers applied to data prior to export
* Should be a class which extends ColModifier
* Can be an object instance or string of a class name
**/
protected $export_modifiers = array();
/** Should this controller log add/edit/delete actions? */
protected $action_log = true;
/**
* Defines the widgets for the refine bar
**/
protected $refine_bar;
/**
* The default number of records to show per page
**/
protected $records_per_page = 50;
/**
* Flag to turn duplication on or off
**/
protected $duplicate_enabled = false;
/**
* Should a UI for editing the "subsite_id" field on a record be shown?
* If enabled by extending classes, then the table should contain a 'subsite_id' INT UNSIGNED column
**/
protected $per_subsite = false;
/**
* Constructor. This must be called in the extending class.
**/
public function __construct()
{
if ($this->controller_name == '') throw new Exception ('Managed controller without a defined name!');
if ($this->friendly_name == '') throw new Exception ('Managed controller without a defined friendly name!');
if ($this->navigation_name == '') $this->navigation_name = $this->friendly_name;
if ($this->main_columns) {
foreach ($this->main_columns as $col) {
if ($col === 'name') {
if (!$this->main_columns) $this->main_columns = array('Name' => 'name'); if (!$this->import_columns) $this->import_columns = array('name'); break;
}
}
}
$this->initTableName();
$this->initRefineBar();
$this->refine_bar->setGroup('General');
$this->refine_bar->addWidget(new RefineWidgetSelect('_date_modified', 'Date modified', Constants::$recent_dates));
$this->refine_bar->addWidget(new RefineWidgetSelect('_date_added', 'Date added', Constants::$recent_dates));
$this->refine_bar->addWidget(new RefineWidgetTextbox('_all_tag', 'All the tags'));
$this->refine_bar->addWidget(new RefineWidgetTextbox('_any_tag', 'Any of the tags'));
$this->main_modes = array('list' => array('Details', 'list')) + $this->main_modes;
Session::instance();
parent::__construct();
}
/**
* Initialises the refine bar if it isn't already set, with a search widget for the 'name' field if it exists
* Most controllers which need a custom refine bar should call this before adding their own search widgets
* @return void The new {@see RefineBar} is set as $this->refine_bar
*/
protected function initRefineBar()
{
if ($this->refine_bar) return;
$this->refine_bar = new RefineBar();
if (!$this->main_columns) return;
foreach ($this->main_columns as $col) {
if ($col === 'name') {
$this->refine_bar->addWidget(new RefineWidgetTextbox('name', 'Name'));
return;
}
}
}
/**
* Initialises the table name if it isn't already set, using the plural of the shorthand controller name
* @return void
*/
protected function initTableName()
{
if ($this->table_name) return;
$this->table_name = Inflector::plural($this->controller_name);
}
/**
* Returns the defined controller name.
**/
final public function getControllerName() {
return $this->controller_name;
}
/**
* Returns the defined controller friendly name
**/
final public function getFriendlyName() {
return $this->friendly_name;
}
/**
* Returns the defined controller navigation name
**/
final public function getNavigationName() {
return $this->navigation_name;
}
/**
* Returns the defined table name
**/
final public function getTableName() {
return $this->table_name;
}
/**
* Gets the name of the controller to use for the top nav
**/
public function getTopnavName()
{
return $this->controller_name;
}
/**
* Returns the duplication enabling flag
**/
final public function getDuplicateEnabled() {
return $this->duplicate_enabled;
}
/**
* If true, then a UI for editing the "subsite_id" for a record should be shown
**/
final public function isPerSubsite() {
return $this->per_subsite;
}
/**
* Returns the intro HTML for this controller.
**/
public function _intro()
{
Url::redirect('admin/contents/' . $this->controller_name);
}
/**
* Returns the SQL query for use by the export tools.
* The query does MUST NOT include a LIMIT clause.
*
* @param string $where A where clause to use.
* Generated based on the specified refine options.
**/
protected function _getExportQuery($where = '1')
{
$q = "SELECT item.*
FROM ~{$this->table_name} AS item
WHERE {$where}
ORDER BY item.id";
return $q;
}
/**
* Applies filters defined in the query string using a LIKE contains
* Only fields which exist in the RefineBar will be filtered
* @param array $source_data Source data, e.g. $_GET or $_POST
* @return array Three elements:
* [0] (array) WHERE clauses, to be joined by the calling code with AND
* [1] (array) Params to use in a Pdb::q call which uses the generated WHERE clauses
* [2] (array) Key-value pairs containing filter options extracted from the $_GET data
*/
protected function applyRefineFilter
(array $source_data = null) {
if (empty($source_data)) { $source_data = $_GET;
}
$where = [];
$params = [];
$fields = [];
foreach ($source_data as $key => $val) {
if (!$this->refine_bar->hasField($key)) continue;
if ($val == '') continue;
$fields[$key] = $val;
if ($key[0] == '_') {
$str = $this->_getRefineClause($key, $val, $params);
if ($str) $where[] = $str;
} else {
$op = $this->refine_bar->getOperator($key);
// If operator is not specified then auto-determine; strings CONTAINS, numbers =
if (preg_match('/^[-+]?([0-9]+\.)?[0-9]+$/', $val)) { $op = '=';
} else {
$op = 'CONTAINS';
}
}
$conditions = [["item.{$key}", $op, $val]];
$where[] = Pdb::buildClause($conditions, $params);
}
}
return [$where, $params, $fields];
}
/**
* Returns form for doing exports
**/
public function _getExport()
{
$export = new View("sprout/admin/generic_export");
$export->controller_name = $this->controller_name;
$export->friendly_name = $this->friendly_name;
// Build the refine bar, adding the 'category' field if required
if ($this->refine_bar) {
$export->refine = $this->refine_bar->get();
}
// Apply filter
list($where, $params, $export->refine_fields) = $this->applyRefineFilter();
// Query which gets three records for the preview
if ($this->main_where) $where = array_merge($where, $this->main_where); if ($where == '') $where = '1';
$q = $this->_getExportQuery($where) . ' LIMIT 3';
$items = Pdb::q($q, $params, 'arr');
// Clean up fields which are too large and build the column list
$modifiers = $this->export_modifiers;
foreach ($items as &$row) {
foreach ($row as $key => $junk) {
if (isset($modifiers[$key]) and
$modifiers[$key] === false) continue; $cols[$key] = $key;
}
}
foreach ($row as $key => &$val) {
if (!empty($modifiers[$key])) { if (is_string($modifiers[$key])) $modifiers[$key] = new $modifiers[$key](); $val = $modifiers[$key]->modify($val, $key);
}
}
foreach ($row as $key => &$val) {
}
}
// Create the itemlist for the preview section
if (count($items) == 0) { $export->itemlist = '<p><i>No records found which match the refinebar clauses specified.</i></p>';
} else {
$itemlist = new Itemlist();
$itemlist->main_columns = $cols;
$itemlist->items = $items;
$export->itemlist = $itemlist->render();
}
'title' => 'Export ' . Enc
::html(strtolower($this->friendly_name)), 'content' => $export->render(),
);
}
/**
* Does the actual export. Return false on error.
*
* @return array [
* 'type' => the content type
* 'filename' => filename
* 'data' => the data itself
* ]
**/
public function _exportData()
{
// Apply filter
list($where, $params) = $this->applyRefineFilter($_POST);
// Query which gets the CSV records
if ($this->main_where) $where = array_merge($where, $this->main_where); if ($where == '') $where = '1';
$q = $this->_getExportQuery($where);
$res = Pdb::query($q, $params, 'pdo');
// Do the export
switch ($_POST['format']) {
case 'csv':
$data = QueryTo::csv($res, $this->export_modifiers);
if (! $data) return false;
return array('type' => 'text/csv; charset=UTF-8', 'filename' => $filename . '.csv', 'data' => $data);
case 'xml':
$data = QueryTo::xml($res, $this->export_modifiers);
if (! $data) return false;
return array('type' => 'application/xml', 'filename' => $filename . '.xml', 'data' => $data); }
// Is closed by QueryTo::csv, but remains open otherwise
$res->closeCursor();
return false;
}
/**
* Returns a form which contains options for doing an export
**/
public function _getImport($filename)
{
$csv = new ImportCSV($filename, $this->import_headings);
$headings = $csv->getHeadings();
// Build data sample
$num = 0;
while ($line = $csv->getNamedLine()) {
foreach ($line as $col => $val) {
if ($val) $sample[$col][] = $val;
}
if ($num++ >= 3) break;
}
// Find columns in database table
$q = "SHOW COLUMNS FROM ~{$this->table_name}";
$res = Pdb::q($q, [], 'arr-num');
// Make the names pretty
foreach ($res as $row) {
}
// Try to auto-match to import fields
$data['duplicates'] = 'new';
foreach ($headings as $idx => $h) {
$csv_heading = trim($headings[$idx]);
$found_col = $this->_importColGuess($csv_heading);
if (!$found_col) {
foreach ($db_columns as $col => $name) {
if (strcasecmp($col, $csv_heading) == 0) { $found_col = $col; break; } if (strcasecmp($name, $csv_heading) == 0) { $found_col = $col; break; } }
}
if ($found_col) {
$data['columns'][Enc::httpfield($csv_heading)] = $found_col;
}
}
// Replace the pre-filled values with session values if found
if (!empty($_SESSION['admin']['field_values'])) { $data = $_SESSION['admin']['field_values'];
unset ($_SESSION['admin']['field_values']); }
// Prepare the view
try {
$view = new View("sprout/admin/{$this->controller_name}_import");
} catch (Exception $ex) {
$view = new View("sprout/admin/generic_import");
}
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->headings = $headings;
$view->sample = $sample;
$view->import_columns = $db_columns;
$view->data = $data;
$view->duplicate_options = ($this->import_duplicates == '');
$view->extra_options = $this->_importExtraOptions();
$title = 'Import ' . Enc
::html(strtolower($this->friendly_name));
'title' => $title,
'content' => $view->render(),
);
}
/**
* Does the actual import
*
* @param string $filename The location of the import data, in a temporary directory
**/
public function _importData($filename)
{
$_SESSION['admin']['field_values'] = Validator
::trim($_POST);
$csv = new ImportCSV($filename, $this->import_headings);
$headings = $csv->getHeadings();
$real_from_post = array(); foreach ($headings as $name) {
$real_from_post[Enc::httpfield($name)] = $name;
}
if ($this->import_duplicates) {
$_POST['duplicates'] = $this->import_duplicates;
}
$error = false;
$valid = new Validator($_POST);
$valid->required(['duplicates']);
if ($_POST['duplicates'] != 'new') {
$valid->required(['match_field']);
}
if (empty($_POST['columns'])) { Notification::error ('No column mappings defined');
$error = true;
} else {
$_POST['columns']['id'] = 'id';
$match_csv = null;
foreach ($_POST['columns'] as $csv_name => $db_name) {
if (isset($real_from_post[$csv_name])) { $csv_name = $real_from_post[$csv_name];
if ($db_name == @$_POST['match_field']) {
$match_csv = $csv_name;
$match_db = $db_name;
break;
}
}
}
if (!$match_csv and $_POST['duplicates'] != 'new') {
Notification::error ('Field used for duplicate matching does not have a column mapping defined');
$error = true;
}
}
if ($valid->hasErrors()) {
$_SESSION['admin']['field_errors'] = $valid->getFieldErrors();
$valid->createNotifications();
$error = true;
}
if ($error) return false;
Pdb::transact();
$res = $this->_importPre();
if (! $res) return false;
while ($line = $csv->getNamedLine()) {
// Ignore completely blank lines
$blank = true;
foreach ($line as $field) {
$blank = false;
break;
}
}
if ($blank) continue;
// Look for a duplicate
$is_duplicate = false;
$existing_record = false;
if ($_POST['duplicates'] != 'new') {
Pdb::validateIdentifier($match_db);
$q = "SELECT *
FROM ~{$this->table_name}
WHERE {$match_db} = ? ORDER BY id";
try {
$existing_record = Pdb::q($q, [$line[$match_csv]], 'row');
$is_duplicate = true;
} catch (RowMissingException $ex) {
// No problem
}
}
// Prepare the field values
foreach ($_POST['columns'] as $csv_name => $db_name) {
if ($db_name == null) continue;
if (!isset($real_from_post[$csv_name])) continue;
$csv_name = $real_from_post[$csv_name];
$new_data[$db_name] = trim($line[$csv_name]); }
// Do pre-import processing
$res = $this->_importPreRecord($new_data, $line);
if (! $res) continue;
// Prepare data for insert/update
foreach ($new_data as $key => $val) {
$field_values[$key] = $val;
}
// Kill off the id column
if (! $this->import_id_column) {
unset ($field_values['id']); }
if ($is_duplicate) {
// Has a duplicate record, do the appropriate action
switch ($_POST['duplicates']) {
case 'new':
$field_values['date_added'] = Pdb::now();
$field_values['date_modified'] = Pdb::now();
$record_id = Pdb::insert($this->table_name, $field_values);
$type = 'insert';
break;
case 'merge_blank':
foreach ($field_values as $col => $val) {
if ($val == '' or $val == 'NULL' or $val == "''") {
unset ($field_values[$col]); }
}
if (empty($field_values)) continue 2; // fall-through
case 'merge':
$field_values['date_modified'] = Pdb::now();
Pdb::update($this->table_name, $field_values, ['id' => $existing_record['id']]);
$record_id = $existing_record['id'];
$type = 'update';
break;
case 'skip':
continue 2;
}
} else {
// No dupe, just do an insert
$field_values['date_added'] = Pdb::now();
$field_values['date_modified'] = Pdb::now();
$record_id = Pdb::insert($this->table_name, $field_values);
$type = 'insert';
}
// Do post-import processing
$res = $this->_importPostRecord($record_id, $new_data, $existing_record, $type, $line);
if (! $res) return false;
}
$res = $this->_importPost();
if (! $res) return false;
Pdb::commit();
return true;
}
/**
* Try to guess the database name for a given CSV heading.
* If you can't figure it out, return NULL.
* If NULL is returned, the rudimentry almost-exact guesser will be run.
*
* @param string $csv_heading The exact heading provided in the CSV file.
* @return string The database field name to use. Must exactly match the database field name.
**/
protected function _importColGuess($csv_heading) { return null; }
/**
* Called when the import form is being built.
*
* Returns HTML of extra options to display, or null if no extra options.
**/
protected function _importExtraOptions () { return null; }
/**
* Called at the beginning of the the import process.
* Is called from within a transaction.
* Return FALSE to abort the import.
**/
protected function _importPre() { return true; }
/**
* Called after the field data has been determined, but before the insert or update is run.
*
* Return FALSE to skip the record.
*
* @param array $new_data The CSV data, with database-mapped names, but before
* the database quoting has happened.
* This is a by-reference argument.
* @param array $raw_data Raw CSV data, with original field names.
**/
protected function _importPreRecord(&$new_data, $raw_data) { return true; }
/**
* Called after a record has been inserted or updated.
*
* @param int $record_id The id of the record that was inserted or updated.
* @param array $new_data The new data of the record.
* @param array $existing_record The old data of the record, which has now been replaced.
* @param string $type One of 'insert' or 'update'
* @param array $raw_data Raw CSV data, with original field names.
* @return boolean False if any errors are encountered; will cancel the entire import process.
**/
protected function _importPostRecord ($record_id, $new_data, $existing_record, $type, $raw_data) { return true; }
/**
* Called at the end of the the import process, after everything has been done.
* Is called from within a transaction.
* Return FALSE to abort the import.
**/
protected function _importPost() { return true; }
/**
* Return the WHERE clause to use for a given key which is provided by the RefineBar
* This must be called in the extending class if no clause can be determined,
* i.e. return parent::_getRefineClause()
*
* Allows custom non-table clauses to be added.
* Is only called for key names which begin with an underscore.
* The base table is aliased to 'item'.
*
* @param string $key The key name, including underscore
* @param string $val The value which is being refined.
* @param array &$query_params Parameters to add to the query which will use the WHERE clause
* @return string WHERE clause, e.g. "item.name LIKE CONCAT('%', ?, '%')", "item.status IN (?, ?, ?)"
*/
protected function _getRefineClause
($key, $val, array &$query_params) {
// Some extra logic for the tag search
if ($key == '_all_tag' or $key == '_any_tag') {
$tags = Tags::splitupTags($val);
}
if (in_array($key, ['_date_added', '_date_modified'])) { $val = (int) $val;
$valid_intervals = [
'MICROSECOND',
'SECOND',
'MINUTE',
'HOUR',
'DAY',
'WEEK',
'MONTH',
'QUARTER',
'YEAR',
'SECOND_MICROSECOND',
'MINUTE_MICROSECOND',
'MINUTE_SECOND',
'HOUR_MICROSECOND',
'HOUR_SECOND',
'HOUR_MINUTE',
'DAY_MICROSECOND',
'DAY_SECOND',
'DAY_MINUTE',
'DAY_HOUR',
'YEAR_MONTH',
];
if (!in_array($interval, $valid_intervals)) { throw new InvalidArgumentException('Invalid interval');
}
}
switch ($key) {
case '_date_modified':
$query_params[] = $val;
return "item.date_modified >= DATE_SUB(NOW(), INTERVAL ? {$interval})";
case '_date_added':
$query_params[] = $val;
return "item.date_added >= DATE_SUB(NOW(), INTERVAL ? {$interval})";
case '_all_tag':
$query_params[] = $tbl;
return "(SELECT COUNT(id) FROM sprout_tags WHERE record_table = ? AND record_id = item.id AND name IN ({$tagwhere})) = " . count($tags);
case '_any_tag':
$query_params[] = $tbl;
return "(SELECT COUNT(id) FROM sprout_tags WHERE record_table = ? AND record_id = item.id AND name IN ({$tagwhere})) >= 1";
}
return null;
}
/**
* Return HTML for a search form
**/
public function _getSearchForm()
{
$view = new View("sprout/admin/generic_search");
// Build the outer view
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->refine = $this->refine_bar;
$view = $view->render();
'title' => 'Search ' . Enc::html($this->friendly_name),
'content' => $view,
);
}
/**
* Returns the SQL query for use by the contents list.
*
* The query MUST NOT include a LIMIT clause.
* The query MUST include a SQL_CALC_FOUND_ROWS clause.
* The main table SHOULD be aliased to 'item'.
*
* @param string $where A where clause to use.
* Generated based on the specified refine options.
* @param string $order An order clause to use.
* @param array $params Params to bind to the query. These will be modified to include per-record permissions
* @return string A SQL query.
**/
protected function _getContentsQuery($where, $order, &$params)
{
$joins = '';
// Determine if per-record permissions used for this controller
// If so, and there's at least one per-record restriction,
// ensure that records which the user can't access aren't displayed
$restrict = PerRecordPerms::controllerRestricted($this);
if ($restrict) {
$has_record_perms = PerRecordPerms::hasRecordPerms($this);
if ($has_record_perms) {
$joins = "LEFT JOIN ~per_record_permissions AS rec_perm
ON rec_perm.controller = ? AND item.id = rec_perm.item_id";
$cat_clause = PerRecordPerms::getCategoryClause();
$cat_clause = substr($cat_clause, 1, -1); // nuke leading and trailing brackets
$where .= " AND (operator_categories IS NULL OR {$cat_clause})";
}
}
$q = "SELECT SQL_CALC_FOUND_ROWS item.*
FROM ~{$this->table_name} AS item
{$joins}
WHERE {$where}
ORDER BY {$order}";
return $q;
}
/**
* Return HTML which represents a list of records for this controller
**/
public function _getContents()
{
if (empty($_GET['page'])) $_GET['page'] = 1; $_GET['page'] = (int) $_GET['page'];
// Apply filter
list($where, $params) = $this->applyRefineFilter();
// Build the where clause
$has_refine = (bool
) count($where); if ($this->main_where) $where = array_merge($where, $this->main_where); if ($where == '') $where = '1';
// Determine record order
$_GET['order'] = preg_replace('/[^_a-z0-9]/', '', @$_GET['order']); if (!empty($_GET['order'])) { Pdb::validateIdentifier($_GET['order']);
$order = "item.{$_GET['order']}";
if (@$_GET['dir'] == 'asc' or @$_GET['dir'] == 'desc') {
$order .= ' ' . $_GET['dir'];
} else {
$_GET['dir'] = 'asc';
}
} else {
$order = $this->main_order;
preg_match('/(item\.)?([_a-z]+)( asc| desc)?/i', $this->main_order, $matches); $_GET['order'] = trim($matches[2]); }
// Get the actual records
$offset = $this->records_per_page * ($_GET['page'] - 1);
$q = $this->_getContentsQuery($where, $order, $params);
$q .= " LIMIT {$this->records_per_page} OFFSET {$offset}";
$items = Pdb::q($q, $params, 'arr');
// Get the total number of records
$q = "SELECT FOUND_ROWS() AS C";
$total_row_count = Pdb::q($q, [], 'val');
// If no mode set, use the session
// If a mode is set and valid, save in the session
if (empty($_GET['main_mode'])) { $_GET['main_mode'] = @$_SESSION['admin'][$this->controller_name]['main_mode'];
} else if ($this->main_modes[$_GET['main_mode']]) {
$_SESSION['admin'][$this->controller_name]['main_mode'] = $_GET['main_mode'];
}
// If no valid mode set, use a default
if (!isset($this->main_modes[$_GET['main_mode']])) { $_GET['main_mode'] = key($this->main_modes); }
// Build the refine bar
if ($this->refine_bar) {
$refine = $this->refine_bar->get();
} else {
$refine = '';
}
// Build the mode selector ui
if (count($this->main_modes) > 1) { $mode_sel = $this->_modeSelector($_GET['main_mode']);
} else {
$mode_sel = '';
}
// If there is no records, tell the user
if ($total_row_count == 0) {
if ($has_refine) {
$items_view = '<p>No records were found which match the specified refinements.</p>';
} else {
$items_view = '<p>No records currently exist in the database.</p>';
}
} else {
$items_view = $this->_getContentsView($items, $_GET['main_mode'], null);
}
// Build the pagination bar
if ($total_row_count > $this->records_per_page) {
$paginate = $this->_paginationBar($_GET['page'], $total_row_count);
} else {
$paginate = '';
}
'title' => Enc::html($this->friendly_name),
'content' => $refine . $mode_sel . $items_view . $paginate,
);
}
/**
* Return HTML for a resultset of items
* The returned HTML will be sandwiched between the refinebar and the pagination bar.
*
* @param Traversable $items The items to render.
* @param string $mode The mode of the display.
* @param anything $unused Not used in this controller, but used by has_categories
**/
public function _getContentsView($items, $mode, $unused)
{
return $this->_getContentsViewList($items, $unused);
}
/**
* Formats a resultset of items into an Itemlist
*
* @param Traversable $items The items to render.
* @param anything $unused Not used in this controller, but used by has_categories
**/
public function _getContentsViewList($items, $unused)
{
// Create the itemlist
$itemlist = new Itemlist();
$itemlist->main_columns = $this->main_columns;
$itemlist->items = $items;
$itemlist->setCheckboxes(true);
$itemlist->setOrdering(true);
$itemlist->setActionsClasses('button button-small');
// Add the actions
$itemlist->addAction('edit', "SITE/admin/edit/{$this->controller_name}/%%");
foreach ($this->main_actions as $name => $url) {
$itemlist->addAction($name, $url, 'button-grey');
}
if ($this->getDuplicateEnabled()) {
$itemlist->addAction('Duplicate', "SITE/admin/duplicate/{$this->controller_name}/%%", 'button-grey icon-before icon-add');
}
if ($this->main_delete) {
$itemlist->addAction('Delete', "SITE/admin/delete/{$this->controller_name}/%%", 'button button-red icon-before icon-delete');
}
// Add classes based on visibility fields
$visibility = $this->_getVisibilityFields();
$itemlist->setRowClassesFunc(function($row) use($visibility) {
$out = '';
foreach ($visibility as $name => $label) {
$out .= "main-list--{$name}-{$row[$name]} ";
}
});
// Prepare view which renders the main content area
$outer = new View("sprout/admin/generic_itemlist_outer");
// Build the outer view
$outer->controller_name = $this->controller_name;
$outer->friendly_name = $this->friendly_name;
$outer->itemlist = $itemlist->render();
$outer->allow_add = $this->main_add;
$outer->allow_del = $this->main_delete;
return $outer->render();
}
/**
* Builds the HTML for showing the navigation through pages in the admin.
* This method is FINAL to help keep the user interface consistent.
*
* @param $current_page The current page. 1-based index.
* @param $total_row_count The total number of records in the dataset.
* @return HTML for the paginate bar.
**/
final protected function _paginationBar($current_page, $total_row_count) {
$total_page_count = ceil($total_row_count / $this->records_per_page);
$paginate = "<div class=\"paginate-bar\">";
$paginate .= "<p class=\"paginate-bar-total\">{$total_row_count} records</p>";
$paginate .= "<div class=\"paginate-bar-buttons\">";
if ($current_page > 1) {
$url = Url::withoutArgs('page') . 'page=' . ($current_page - 1);
$paginate .= "<a class=\"paginate-bar-button paginate-bar-previous button button-blue button-small icon-before icon-keyboard_arrow_left\" href=\"{$url}\">Prev</a>";
}
$paginate .= "<p class=\"paginate-bar-current-page\">Page {$current_page} of {$total_page_count}</p>";
if ($current_page < $total_page_count) {
$url = Url::withoutArgs('page') . 'page=' . ($current_page + 1);
$paginate .= "<a class=\"paginate-bar-button paginate-bar-next button button-blue button-small icon-after icon-keyboard_arrow_right\" href=\"{$url}\">Next</a>";
}
$paginate .= "</div>";
$paginate .= "</div>";
return $paginate;
}
/**
* Returns HTML for a ui component to update the current main view mode
**/
final protected function _modeSelector($current_mode) {
$base = Url::withoutArgs('main_mode');
echo '<div class="mode-selector">';
foreach ($this->main_modes as $key => $val) {
if ($key == $current_mode) {
echo '<a href="', $base, 'main_mode=', $key, '" class="button button-orange button-regular button-icon icon-before';
} else {
echo '<a href="', $base, 'main_mode=', $key, '" class="button button-grey button-regular button-icon icon-before';
}
list ($label, $icon) = $val;
// Set the icon using the icon font class
if ($icon === "list") {
$icon = "view_list";
} else if ($icon === "grid") {
$icon = "view_module";
}
echo ' icon-', $icon, '" title="', Enc::html($label), '"><span class="-vis-hidden">' . Enc::html($label) . "</span>";
} else {
// Not an array? assume no icon
echo '"><span>' . Enc::html($val) . '</span>';
}
echo '</a>';
}
echo '</div>';
}
/**
* Returns a page title and HTML for a form to add a record
* @return array Two elements: 'title' and 'content'
*/
public function _getAddForm()
{
$data = $this->add_defaults;
} else {
$data = [];
}
if (!empty($_SESSION['admin']['field_values'])) { $data = $_SESSION['admin']['field_values'];
unset($_SESSION['admin']['field_values']); }
$errors = [];
if (!empty($_SESSION['admin']['field_errors'])) { $errors = $_SESSION['admin']['field_errors'];
unset($_SESSION['admin']['field_errors']); }
// Auto-generate form from JSON where possible
$conf = false;
try {
$conf = $this->loadEditJson();
$view = new View('sprout/auto_edit');
$view->id = 0;
$view->config = $conf;
} catch (FileMissingException $ex) {
$view_dir = $this->getModulePath();
$view = new View("{$view_dir}/admin/{$this->controller_name}_add");
}
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->data = $data;
$view->errors = $errors;
$this->_addPreRender($view);
'title' => 'Adding ' . Enc::html(Inflector::singular($this->friendly_name)),
'content' => $view->render()
);
}
/**
* Is the "add" action saved?
* These may be false if the UI provides its own save mechanism (e.g. multi-add)
*
* @return bool True if they are saved, false if they are not
*/
public function _isAddSaved()
{
return true;
}
/**
* Optional custom HTML for the save box
* Return NULL to use the default HTML
*
* @param return string HTML
*/
public function _getCustomAddSaveHTML()
{
return null;
}
/**
* Return the fields to show in the sidebar when adding or editing a record.
* These fields are shown under a heading of "Visibility"
*
* Key is the field name, value is the field label
*
* @return array
*/
public function _getVisibilityFields()
{
return [
'active' => 'Active',
];
}
/**
* Inject the visiblity fields into a loaded json configuration, so they actually save
*
* @param array $conf JSON add/edit configuration
*/
protected function injectVisiblityFields
(array &$conf) {
$conf['_visibility'] = [];
$visibility = $this->_getVisibilityFields();
foreach ($visibility as $name => $label) {
$conf['_visibility'][] = ['field' => [
'name' => $name,
'label' => $label,
]];
}
if ($this->per_subsite) {
$conf['_visibility'][] = ['field' => [
'name' => 'subsite_id',
'label' => 'Subsite',
'empty' => null,
]];
}
}
/**
* Return the sub-actions for adding a record (e.g. preview)
* These are rendered into HTML using {@see AdminController::renderSubActions}
*
* @return array
*/
public function _getAddSubActions()
{
return [];
}
/**
* Hook called by _getAddForm() just before the view is rendered
*
* @tag api
* @tag module-api
**/
protected function _addPreRender($view) {}
protected function _preSave($id, &$data)
{
if ($id == 0) {
$data['date_added'] = Pdb::now();
}
$data['date_modified'] = Pdb::now();
}
/**
* Process the saving of an add.
*
* @param int $item_id The new record id should be returned in this variable
* @return boolean True on success, false on failure
*/
public function _addSave(&$item_id)
{
// Auto-process form using JSON config
$conf = $this->loadEditJson();
$this->injectVisiblityFields($conf);
return $this->saveJsonData($conf, $item_id);
}
/**
* Fetch the record with a given id
*
* @param int $id Record to fetch
* @return array Database row
*/
protected function _getRecord($id)
{
return Pdb::get($this->table_name, $id);
}
/**
* Returns a page title and HTML for a form to edit a record
*
* @param int $id The id of the record to get the edit form of
* @return array Two elements, 'title' and 'content'
*/
public function _getEditForm($id)
{
$id = (int) $id;
if ($id <= 0) throw new InvalidArgumentException('$id must be greater than 0');
// Get the item
try {
$item = $this->_getRecord($id);
$data = $item;
} catch (RowMissingException $ex) {
$single = Inflector::singular($this->friendly_name);
return new AdminError("Invalid id specified - {$single} does not exist");
}
// Auto-generate form from JSON where possible
$conf = false;
try {
$conf = $this->loadEditJson();
$view = new View('sprout/auto_edit');
$view->config = $conf;
$default_link = Inflector::singular($this->table_name) . '_id';
$data = array_merge($data, JsonForm
::loadMultiEditData($conf, $default_link, $id, [])); $data = array_merge($data, JsonForm
::loadAutofillListData($conf, $this->table_name, $id, [])); } catch (FileMissingException $ex) {
$view_dir = $this->getModulePath();
$view = new View("{$view_dir}/admin/{$this->controller_name}_edit");
}
// Overlay session data
if (!empty($_SESSION['admin']['field_values'])) { $data = $_SESSION['admin']['field_values'];
unset($_SESSION['admin']['field_values']); }
$errors = [];
if (!empty($_SESSION['admin']['field_errors'])) { $errors = $_SESSION['admin']['field_errors'];
unset($_SESSION['admin']['field_errors']); }
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->id = $id;
$view->data = $data;
$view->errors = $errors;
$this->_editPreRender($view, $id);
$title = 'Editing ' . Enc::html(Inflector::singular($this->friendly_name));
'title' => $title . ' <strong>' . Enc::html($this->_identifier($item)) . '</strong>',
'content' => $view->render()
);
}
/**
* Is the "edit" action saved?
* These may be false if the UI provides its own save mechanism
*
* @return bool True if they are saved, false if they are not
*/
public function _isEditSaved($item_id)
{
return true;
}
/**
* Optional custom HTML for the save box
* Return NULL to use the default HTML
*
* @param return string HTML
*/
public function _getCustomEditSaveHTML($item_id)
{
return null;
}
/**
* Return the sub-actions for editing a record (e.g. deleting)
* These are rendered into HTML using {@see AdminController::renderSubActions}
*
* @return array Each key is a unique reference to the action, e.g. 'delete', and the value is an array, with keys:
* url => URL to link to, e.g. "admin/delete/thing/$item_id"
* name => Label to display to the user, e.g. 'Delete'
* class => CSS class(es) for the icon, e.g. 'icon-link-button icon-before icon-delete'
* new_tab => True to show in new window/tab (optional; defaults to false)
*/
public function _getEditSubActions($item_id)
{
$actions = [];
if ($this->_isDeleteSaved($item_id)) {
$actions['delete'] = [
'url' => 'admin/delete/' . $this->controller_name . '/' . $item_id,
'name' => 'Delete',
'class' => 'icon-link-button icon-before icon-delete',
];
}
return $actions;
}
/**
* Return the URL to use for the 'view live site' button, when editing a given record
*
* @param int $item_id Record which is being editied
* @return string URL, either absolute or relative
* @return null Default url should be used
*/
public function _getEditLiveUrl($item_id)
{
return null;
}
/**
* Hook called by _getEditForm() just before the view is rendered
*
* @tag api
* @tag module-api
**/
protected function _editPreRender($view, $item_id) {}
/**
* Process the saving of a record.
*
* @param int $item_id The ID of the record to save the data into
* @return boolean True on success, false on failure
*/
public function _editSave($item_id)
{
$item_id = (int) $item_id;
if ($item_id <= 0) throw new InvalidArgumentException('$item_id must be greater than 0');
// Auto-process form using JSON config
$conf = $this->loadEditJson();
$this->injectVisiblityFields($conf);
return $this->saveJsonData($conf, $item_id);
}
/**
* Optional custom HTML for the save box
* Return NULL to use the default HTML
*
* @param return string HTML
*/
public function _getCustomDuplicateSaveHTML($item_id)
{
return null;
}
/**
* Return the sub-actions for duplicating a record
* These are rendered into HTML using {@see AdminController::renderSubActions}
*
* @return array
*/
public function _getDuplicateSubActions($item_id)
{
return [];
}
/**
* Return HTML which represents the form for duplicating a record
*
* @param int $id The id of the record to get the original data from
**/
public function _getDuplicateForm($id)
{
$id = (int) $id;
if ($id <= 0) throw new InvalidArgumentException('$id must be greater than 0');
// Get the item
$data = $item = $this->_getRecord($id);
// Clobber duplication fields with any defaults defined in controller
if (@count($this->duplicate_defaults)) { foreach ($this->duplicate_defaults as $key => $val) {
$data[$key] = $val;
}
}
if (!empty($_SESSION['admin']['field_values'])) { $data = $_SESSION['admin']['field_values'];
unset ($_SESSION['admin']['field_values']); }
$errors = [];
if (!empty($_SESSION['admin']['field_errors'])) { $errors = $_SESSION['admin']['field_errors'];
unset($_SESSION['admin']['field_errors']); }
// Auto-generate form from JSON where possible
$conf = false;
try {
$conf = $this->loadEditJson();
$view = new View('sprout/auto_edit');
$view->config = $conf;
$default_link = Inflector::singular($this->table_name) . '_id';
$data = array_merge($data, JsonForm
::loadMultiEditData($conf, $default_link, $id, [])); $data = array_merge($data, JsonForm
::loadAutofillListData($conf, $this->table_name, $id, [])); } catch (FileMissingException $ex) {
$view_dir = $this->getModulePath();
$view = new View("{$view_dir}/admin/{$this->controller_name}_edit");
}
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->id = $id;
$view->item = $item;
$view->data = $data;
$view->errors = $errors;
$this->_duplicatePreRender($view, $id);
$title = 'Duplicating ' . Enc::html(Inflector::singular($this->friendly_name));
'title' => $title . ' <strong>' . Enc::html($this->_identifier($item)) . '</strong>',
'content' => $view->render()
);
}
/**
* Hook called by _getDuplicateForm() just before the view is rendered
*
* @tag api
* @tag module-api
**/
protected function _duplicatePreRender($view, $item_id)
{
$this->_editPreRender($view, $item_id);
}
/**
* Process the saving of a duplication. Basic version just calls _editSave
*
* @param int $id The record to save
* @return boolean True on success, false on failure
**/
public function _duplicateSave($id)
{
return $this->_editSave($id);
}
/**
* Return HTML which represents the form for deleting a record
*
* @param int $id The record to show the delete form for
* @return string The HTML code which represents the edit form
**/
public function _getDeleteForm($id)
{
$id = (int) $id;
try {
$view = new View("{$this->getModulePath()}/admin/{$this->controller_name}_delete");
} catch (FileMissingException $ex) {
$view = new View("sprout/admin/generic_delete");
}
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->id = $id;
// Load item details
try {
$view->item = $this->_getRecord($id);
} catch (RowMissingException $ex) {
return [
'title' => 'Error',
'content' => "Invalid id specified - {$this->controller_name} does not exist",
];
}
'title' => 'Deleting ' . Enc::html(Inflector::singular($this->friendly_name)) . ' <strong>' . Enc::html($this->_identifier($view->item)) . '</strong>',
'content' => $view->render()
);
}
/**
* Check if deletion of a particular record is allowed
* This method may be overridden if ignoring the $main_delete property is desired
* @param int $item_id
* @return bool True if they are saved, false if they are not
*/
public function _isDeleteSaved($item_id)
{
return true;
}
/**
* Return the sub-actions for deleting a record (e.g. cancel)
* These are rendered into HTML using {@see AdminController::renderSubActions}
*
* @return array
*/
public function _getDeleteSubActions($item_id)
{
$actions = [];
$actions['cancel'] = [
'url' => 'admin/edit/' . $this->controller_name . '/' . $item_id,
'name' => 'Cancel',
];
return $actions;
}
/**
* Does custom actions before _deleteSave method is called, e.g. extra security checks
* @param int $item_id The record to delete
* @return void
* @throws Exception if the deletion shouldn't proceed for some reason
*/
public function _deletePreSave($item_id)
{
}
/**
* Does custom actions after the _deleteSave method is called, e.g. clearing cache data
* @param int $item_id The record to delete
* @return void
*/
public function _deletePostSave($item_id)
{
}
/**
* Deletes an item and logs the deleted data
* @param int $item_id The record to delete
* @param bool True on success, false on failure
*/
public function _deleteSave($item_id)
{
$item_id = (int) $item_id;
if (!$this->_isDeleteSaved($item_id)) return false;
$this->deleteRecord($this->table_name, $item_id);
return true;
}
/**
* This is called after every add, edit and delete, as well as other (i.e. bulk) actions.
* Use it to clear any frontend caches. The default is an empty method.
*
* @param string $action The name of the action (e.g. 'add', 'edit', 'delete', etc)
* @param int $item_id The item which was affected. Bulk actions (e.g. reorders) will have this set to NULL.
**/
public function _invalidateCaches($action, $item_id = null) {}
/**
* Return the navigation for this controller
* Should return HTML
**/
abstract public function _getNavigation();
public function _actionLog()
{
return $this->action_log;
}
/**
* Returns tools to show in the left hand navigation. Return an empty array if no tools.
**/
public function _getTools()
{
$friendly = Enc
::html(strtolower($this->friendly_name));
$tools['import'] = "<li class=\"import\"><a href=\"SITE/admin/import_upload/{$this->controller_name}\">Import {$friendly}</a></li>";
$tools['export'] = "<li class=\"export\"><a href=\"SITE/admin/export/{$this->controller_name}\">Export {$friendly}</a></li>";
if ($this->_actionLog()) {
$tools['action_log'] = '<li class="action-log"><a href="SITE/admin/contents/action_log?record_table=' . $this->getTableName() . '">View action log</a></li>';
}
return $tools;
}
/**
* Creates the identifier used in the heading, and for reordering.
* @param array $item The row being viewed/edited/etc.
* @return string
*/
public function _identifier
(array $item) {
if (isset($item['name'])) return $item['name']; if (isset($item['id'])) return "#{$item['id']}"; return '';
}
// This may even be a really bad idea, I haven't decided yet
/* Optional: _extra_<command>($id) */
/**
* Form to delete multiple records
**/
public function _extraMultiDelete()
{
if (! AdminPerms::controllerAccess($this->getControllerName(), 'delete')) {
return new AdminError('Access denied');
}
if (empty($_GET['ids'])) { Notification::error('No items selected for deletion');
Url::redirect('admin/contents/' . $this->controller_name);
}
$view = new View('sprout/admin/categories_multi_delete');
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->ids = $_GET['ids'];
return $view;
}
/**
* Delete multiple records
**/
public function postMultiDelete()
{
Csrf::checkOrDie();
if (! AdminPerms::controllerAccess($this->getControllerName(), 'delete')) {
Notification::error('Access denied');
Url::redirect('admin/contents/' . $this->controller_name);
}
if (empty($_POST['ids'])) { Notification::error('No items selected for deletion');
Url::redirect('admin/contents/' . $this->controller_name);
}
$success = 0;
$constraint = 0;
foreach ($_POST['ids'] as $item_id) {
try {
$res = $this->_deleteSave($item_id);
if ($res) {
$success++;
}
} catch (ConstraintQueryException $ex) {
if (Pdb::inTransaction()) {
Pdb::rollback();
}
$constraint++;
}
}
$this->_invalidateCaches('multi_delete');
if ($success > 0) {
Notification::confirm('Deletion of ' . $success . ' ' . Inflector::singular($this->getFriendlyName(), $success) . ' was successful');
}
if ($constraint > 0) {
Notification::error($constraint . ' ' . Inflector::singular($this->getFriendlyName(), $constraint) . " in use by other modules and can't be deleted");
}
Url::redirect('admin/contents/' . $this->controller_name);
}
/**
* Multi-tag some items. Uses AJAX. Returns JSON.
**/
public function postJsonMultiTag()
{
Csrf::checkOrDie();
if (! AdminPerms::controllerAccess($this->getControllerName(), 'edit')) {
Json::error('Access denied');
}
if (empty($_POST['ids'])) { Json::error('No items selected for tagging');
}
$_POST['tags'] = trim($_POST['tags']); if ($_POST['tags'] == '') {
Json::error('No tags entered');
}
$new_tags = Tags::splitupTags($_POST['tags']);
foreach ($_POST['ids'] as $item_id) {
Tags::update($this->table_name, $item_id, $new_tags, false);
}
$this->_invalidateCaches('multi_edit');
Json::confirm();
}
/**
* Return list of records for given search term
* Used for Fb::autocomplete
*
* @return void Echos JSON directly
* @throws LogicException
* @throws InvalidArgumentException
* @throws QueryException
* @throws ConnectionException
*/
public function ajaxLookup()
{
AdminAuth::checkLogin();
if (!empty($_GET['id'])) { $q = "SELECT name AS label FROM ~{$this->table_name} WHERE id = ?";
$records = Pdb::query($q, [$_GET['id']], 'arr');
Json::out($records);
}
$q = "SELECT id, name AS value FROM ~{$this->table_name} WHERE name LIKE CONCAT('%', ?, '%')";
$records = Pdb::query($q, [Pdb::likeEscape($_GET['term'])], 'arr');
Json::out($records);
}
/**
* Return list of records for given search term
* Used for Fb::autocompleteList
*
* @return void Echos JSON directly
* @throws LogicException
* @throws InvalidArgumentException
* @throws QueryException
* @throws ConnectionException
*/
public function ajaxLookupList()
{
AdminAuth::checkLogin();
if (!empty($_GET['ids'])) { $conditions = [];
$params = [];
$conditions[] = ['id', 'IN', explode(',', $_GET['ids'])];
$where = Pdb::buildClause($conditions, $params);
$q = "SELECT id, name AS label FROM ~{$this->table_name} WHERE {$where}";
$records = Pdb::query($q, $params, 'arr');
Json::out($records);
}
$q = "SELECT id, name AS value FROM ~{$this->table_name} WHERE name LIKE CONCAT('%', ?, '%')";
$records = Pdb::query($q, [Pdb::likeEscape($_GET['term'])], 'arr');
Json::out($records);
}
}