SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Controllers/Admin/ManagedAdminController.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Controllers\Admin;
  15.  
  16. use Exception;
  17. use InvalidArgumentException;
  18.  
  19. use Sprout\Controllers\Controller;
  20. use Sprout\Exceptions\ConstraintQueryException;
  21. use Sprout\Exceptions\FileMissingException;
  22. use Sprout\Exceptions\RowMissingException;
  23. use Sprout\Helpers\AdminError;
  24. use Sprout\Helpers\AdminPerms;
  25. use Sprout\Helpers\Constants;
  26. use Sprout\Helpers\Csrf;
  27. use Sprout\Helpers\Enc;
  28. use Sprout\Helpers\ImportCSV;
  29. use Sprout\Helpers\Inflector;
  30. use Sprout\Helpers\Itemlist;
  31. use Sprout\Helpers\Json;
  32. use Sprout\Helpers\JsonForm;
  33. use Sprout\Helpers\Notification;
  34. use Sprout\Helpers\Pdb;
  35. use Sprout\Helpers\PerRecordPerms;
  36. use Sprout\Helpers\QueryTo;
  37. use Sprout\Helpers\RefineBar;
  38. use Sprout\Helpers\RefineWidgetSelect;
  39. use Sprout\Helpers\RefineWidgetTextbox;
  40. use Sprout\Helpers\Session;
  41. use Sprout\Helpers\Tags;
  42. use Sprout\Helpers\Url;
  43. use Sprout\Helpers\Validator;
  44. use Sprout\Helpers\View;
  45.  
  46.  
  47. /**
  48. * This is a generic controller which all controllers which are managed in the admin area should extend.
  49. *
  50. * Required fields for a managed controller table:
  51. * id
  52. *
  53. * @tag api
  54. * @tag module-api
  55. **/
  56. abstract class ManagedAdminController extends Controller {
  57. /**
  58.   * This is the name of the controller - should match the class name, but without the '_Controller' bit.
  59.   **/
  60. protected $controller_name;
  61.  
  62. /**
  63.   * This is the friendly name of the controller. In 99% of cases, should be the plural form of the controller name
  64.   **/
  65. protected $friendly_name;
  66.  
  67. /**
  68.   * The friendly name used in the sidebar navigation. Defaults to matching the friendly name.
  69.   **/
  70. protected $navigation_name;
  71.  
  72. /**
  73.   * This is the name of the table to get data from. Will be automatically deducted from the controller name if not specified
  74.   **/
  75. protected $table_name;
  76.  
  77. /**
  78.   * Default values used for adding a record.
  79.   **/
  80. protected $add_defaults;
  81.  
  82. /**
  83.   * Default values used for duplicating a record.
  84.   **/
  85. protected $duplicate_defaults = array(
  86. 'name' => '',
  87. );
  88.  
  89. /**
  90.   * The columns to use for the main view
  91.   **/
  92. protected $main_columns;
  93.  
  94. /**
  95.   * Order of main view records
  96.   **/
  97. protected $main_order = 'item.name';
  98.  
  99. /**
  100.   * An additional where clause for the main view
  101.   **/
  102. protected $main_where = array();
  103.  
  104. /**
  105.   * Actions for the itemlist
  106.   **/
  107. protected $main_actions = array();
  108.  
  109. /**
  110.   * Should a link be shown above the list for adding records? (default yes)
  111.   **/
  112. protected $main_add = true;
  113.  
  114. /** Is deletion allowed, with an option shown in the UI? (default: no) */
  115. protected $main_delete = false;
  116.  
  117. /**
  118.   * Different modes available for the main view
  119.   * By default, there is only one mode: list
  120.   **/
  121. protected $main_modes = array();
  122.  
  123. /**
  124.   * The columns to allow import for
  125.   **/
  126. protected $import_columns;
  127.  
  128. /**
  129.   * The default selection for the "duplicates" option
  130.   * Values are "new", "merge", "merge_blank" and "skip".
  131.   **/
  132. protected $import_duplicates = '';
  133.  
  134. /**
  135.   * Typically, we don't want to import the ID, and just let autoinc do it's thing
  136.   **/
  137. protected $import_id_column = false;
  138.  
  139. /**
  140.   * If a client is providing CSVs which don't have headings
  141.   * You'll need to provide them in this array
  142.   **/
  143. protected $import_headings = null;
  144.  
  145. /**
  146.   * Modifiers applied to data prior to export
  147.   * Should be a class which extends ColModifier
  148.   * Can be an object instance or string of a class name
  149.   **/
  150. protected $export_modifiers = array();
  151.  
  152. /** Should this controller log add/edit/delete actions? */
  153. protected $action_log = true;
  154.  
  155. /**
  156.   * Defines the widgets for the refine bar
  157.   **/
  158. protected $refine_bar;
  159.  
  160. /**
  161.   * The default number of records to show per page
  162.   **/
  163. protected $records_per_page = 50;
  164.  
  165. /**
  166.   * Flag to turn duplication on or off
  167.   **/
  168. protected $duplicate_enabled = false;
  169.  
  170. /**
  171.   * Should a UI for editing the "subsite_id" field on a record be shown?
  172.   * If enabled by extending classes, then the table should contain a 'subsite_id' INT UNSIGNED column
  173.   **/
  174. protected $per_subsite = false;
  175.  
  176.  
  177. /**
  178.   * Constructor. This must be called in the extending class.
  179.   **/
  180. public function __construct()
  181. {
  182. if ($this->controller_name == '') throw new Exception ('Managed controller without a defined name!');
  183. if ($this->friendly_name == '') throw new Exception ('Managed controller without a defined friendly name!');
  184.  
  185. if ($this->navigation_name == '') $this->navigation_name = $this->friendly_name;
  186.  
  187. if ($this->main_columns) {
  188. foreach ($this->main_columns as $col) {
  189. if ($col === 'name') {
  190. if (!$this->main_columns) $this->main_columns = array('Name' => 'name');
  191. if (!$this->import_columns) $this->import_columns = array('name');
  192. break;
  193. }
  194. }
  195. }
  196.  
  197. $this->initTableName();
  198. $this->initRefineBar();
  199.  
  200. $this->refine_bar->setGroup('General');
  201. $this->refine_bar->addWidget(new RefineWidgetSelect('_date_modified', 'Date modified', Constants::$recent_dates));
  202. $this->refine_bar->addWidget(new RefineWidgetSelect('_date_added', 'Date added', Constants::$recent_dates));
  203. $this->refine_bar->addWidget(new RefineWidgetTextbox('_all_tag', 'All the tags'));
  204. $this->refine_bar->addWidget(new RefineWidgetTextbox('_any_tag', 'Any of the tags'));
  205.  
  206. $this->main_modes = array('list' => array('Details', 'list')) + $this->main_modes;
  207.  
  208. Session::instance();
  209.  
  210. parent::__construct();
  211. }
  212.  
  213.  
  214. /**
  215.   * Initialises the refine bar if it isn't already set, with a search widget for the 'name' field if it exists
  216.   * Most controllers which need a custom refine bar should call this before adding their own search widgets
  217.   * @return void The new {@see RefineBar} is set as $this->refine_bar
  218.   */
  219. protected function initRefineBar()
  220. {
  221. if ($this->refine_bar) return;
  222.  
  223. $this->refine_bar = new RefineBar();
  224. if (!$this->main_columns) return;
  225. foreach ($this->main_columns as $col) {
  226. if ($col === 'name') {
  227. $this->refine_bar->addWidget(new RefineWidgetTextbox('name', 'Name'));
  228. return;
  229. }
  230. }
  231. }
  232.  
  233.  
  234. /**
  235.   * Initialises the table name if it isn't already set, using the plural of the shorthand controller name
  236.   * @return void
  237.   */
  238. protected function initTableName()
  239. {
  240. if ($this->table_name) return;
  241. $this->table_name = Inflector::plural($this->controller_name);
  242. }
  243.  
  244.  
  245. /**
  246.   * Returns the defined controller name.
  247.   **/
  248. final public function getControllerName() {
  249. return $this->controller_name;
  250. }
  251.  
  252. /**
  253.   * Returns the defined controller friendly name
  254.   **/
  255. final public function getFriendlyName() {
  256. return $this->friendly_name;
  257. }
  258.  
  259. /**
  260.   * Returns the defined controller navigation name
  261.   **/
  262. final public function getNavigationName() {
  263. return $this->navigation_name;
  264. }
  265.  
  266. /**
  267.   * Returns the defined table name
  268.   **/
  269. final public function getTableName() {
  270. return $this->table_name;
  271. }
  272.  
  273. /**
  274.   * Gets the name of the controller to use for the top nav
  275.   **/
  276. public function getTopnavName()
  277. {
  278. return $this->controller_name;
  279. }
  280.  
  281. /**
  282.   * Returns the duplication enabling flag
  283.   **/
  284. final public function getDuplicateEnabled() {
  285. return $this->duplicate_enabled;
  286. }
  287.  
  288. /**
  289.   * If true, then a UI for editing the "subsite_id" for a record should be shown
  290.   **/
  291. final public function isPerSubsite() {
  292. return $this->per_subsite;
  293. }
  294.  
  295.  
  296. /**
  297.   * Returns the intro HTML for this controller.
  298.   **/
  299. public function _intro()
  300. {
  301. Url::redirect('admin/contents/' . $this->controller_name);
  302. }
  303.  
  304.  
  305. /**
  306.   * Returns the SQL query for use by the export tools.
  307.   * The query does MUST NOT include a LIMIT clause.
  308.   *
  309.   * @param string $where A where clause to use.
  310.   * Generated based on the specified refine options.
  311.   **/
  312. protected function _getExportQuery($where = '1')
  313. {
  314. $q = "SELECT item.*
  315. FROM ~{$this->table_name} AS item
  316. WHERE {$where}
  317. ORDER BY item.id";
  318.  
  319. return $q;
  320. }
  321.  
  322.  
  323. /**
  324.   * Applies filters defined in the query string using a LIKE contains
  325.   * Only fields which exist in the RefineBar will be filtered
  326.   * @param array $source_data Source data, e.g. $_GET or $_POST
  327.   * @return array Three elements:
  328.   * [0] (array) WHERE clauses, to be joined by the calling code with AND
  329.   * [1] (array) Params to use in a Pdb::q call which uses the generated WHERE clauses
  330.   * [2] (array) Key-value pairs containing filter options extracted from the $_GET data
  331.   */
  332. protected function applyRefineFilter(array $source_data = null)
  333. {
  334. if (empty($source_data)) {
  335. $source_data = $_GET;
  336. }
  337. $where = [];
  338. $params = [];
  339. $fields = [];
  340. foreach ($source_data as $key => $val) {
  341. if (!$this->refine_bar->hasField($key)) continue;
  342.  
  343. $val = trim($val);
  344. if ($val == '') continue;
  345.  
  346. $fields[$key] = $val;
  347.  
  348. if ($key[0] == '_') {
  349. $str = $this->_getRefineClause($key, $val, $params);
  350. if ($str) $where[] = $str;
  351. } else {
  352. $op = $this->refine_bar->getOperator($key);
  353.  
  354. // If operator is not specified then auto-determine; strings CONTAINS, numbers =
  355. if (empty($op)) {
  356. if (preg_match('/^[-+]?([0-9]+\.)?[0-9]+$/', $val)) {
  357. $op = '=';
  358. } else {
  359. $op = 'CONTAINS';
  360. }
  361. }
  362.  
  363. $conditions = [["item.{$key}", $op, $val]];
  364. $where[] = Pdb::buildClause($conditions, $params);
  365. }
  366. }
  367. return [$where, $params, $fields];
  368. }
  369.  
  370.  
  371. /**
  372.   * Returns form for doing exports
  373.   **/
  374. public function _getExport()
  375. {
  376. $export = new View("sprout/admin/generic_export");
  377. $export->controller_name = $this->controller_name;
  378. $export->friendly_name = $this->friendly_name;
  379.  
  380. // Build the refine bar, adding the 'category' field if required
  381. if ($this->refine_bar) {
  382. $export->refine = $this->refine_bar->get();
  383. }
  384.  
  385. // Apply filter
  386. list($where, $params, $export->refine_fields) = $this->applyRefineFilter();
  387.  
  388. // Query which gets three records for the preview
  389. if ($this->main_where) $where = array_merge($where, $this->main_where);
  390. $where = implode(' AND ', $where);
  391. if ($where == '') $where = '1';
  392.  
  393. $q = $this->_getExportQuery($where) . ' LIMIT 3';
  394. $items = Pdb::q($q, $params, 'arr');
  395.  
  396. // Clean up fields which are too large and build the column list
  397. $cols = array();
  398. $modifiers = $this->export_modifiers;
  399. foreach ($items as &$row) {
  400. if (count($cols) == 0) {
  401. foreach ($row as $key => $junk) {
  402. if (isset($modifiers[$key]) and $modifiers[$key] === false) continue;
  403. $cols[$key] = $key;
  404. }
  405. }
  406.  
  407. foreach ($row as $key => &$val) {
  408. if (!empty($modifiers[$key])) {
  409. if (is_string($modifiers[$key])) $modifiers[$key] = new $modifiers[$key]();
  410. $val = $modifiers[$key]->modify($val, $key);
  411. }
  412. }
  413.  
  414. foreach ($row as $key => &$val) {
  415. if (strlen($val) > 50) $val = substr($val, 0, 50) . '...';
  416. }
  417. }
  418.  
  419. // Create the itemlist for the preview section
  420. if (count($items) == 0) {
  421. $export->itemlist = '<p><i>No records found which match the refinebar clauses specified.</i></p>';
  422.  
  423. } else {
  424. $itemlist = new Itemlist();
  425. $itemlist->main_columns = $cols;
  426. $itemlist->items = $items;
  427. $export->itemlist = $itemlist->render();
  428. }
  429.  
  430.  
  431. return array(
  432. 'title' => 'Export ' . Enc::html(strtolower($this->friendly_name)),
  433. 'content' => $export->render(),
  434. );
  435. }
  436.  
  437.  
  438. /**
  439.   * Does the actual export. Return false on error.
  440.   *
  441.   * @return array [
  442.   * 'type' => the content type
  443.   * 'filename' => filename
  444.   * 'data' => the data itself
  445.   * ]
  446.   **/
  447. public function _exportData()
  448. {
  449. $filename = strtolower(str_replace(' ', '_', $this->friendly_name)) . '_' . date('Y-m-d');
  450.  
  451. // Apply filter
  452. list($where, $params) = $this->applyRefineFilter($_POST);
  453.  
  454.  
  455. // Query which gets the CSV records
  456. if ($this->main_where) $where = array_merge($where, $this->main_where);
  457. $where = implode(' AND ', $where);
  458. if ($where == '') $where = '1';
  459.  
  460. $q = $this->_getExportQuery($where);
  461. $res = Pdb::query($q, $params, 'pdo');
  462.  
  463.  
  464. // Do the export
  465. switch ($_POST['format']) {
  466. case 'csv':
  467. $data = QueryTo::csv($res, $this->export_modifiers);
  468. if (! $data) return false;
  469.  
  470. return array('type' => 'text/csv; charset=UTF-8', 'filename' => $filename . '.csv', 'data' => $data);
  471.  
  472.  
  473. case 'xml':
  474. $data = QueryTo::xml($res, $this->export_modifiers);
  475. if (! $data) return false;
  476.  
  477. return array('type' => 'application/xml', 'filename' => $filename . '.xml', 'data' => $data);
  478. }
  479.  
  480. // Is closed by QueryTo::csv, but remains open otherwise
  481. $res->closeCursor();
  482.  
  483. return false;
  484. }
  485.  
  486.  
  487. /**
  488.   * Returns a form which contains options for doing an export
  489.   **/
  490. public function _getImport($filename)
  491. {
  492. $csv = new ImportCSV($filename, $this->import_headings);
  493. $headings = $csv->getHeadings();
  494.  
  495. // Build data sample
  496. $sample = array();
  497. $num = 0;
  498. while ($line = $csv->getNamedLine()) {
  499. foreach ($line as $col => $val) {
  500. if ($val) $sample[$col][] = $val;
  501. }
  502. if ($num++ >= 3) break;
  503. }
  504.  
  505. // Find columns in database table
  506. $q = "SHOW COLUMNS FROM ~{$this->table_name}";
  507. $res = Pdb::q($q, [], 'arr-num');
  508.  
  509. // Make the names pretty
  510. $db_columns = array();
  511. foreach ($res as $row) {
  512. $db_columns[$row[0]] = ucfirst(str_replace('_', ' ', $row[0]));
  513. }
  514. asort($db_columns);
  515.  
  516. // Try to auto-match to import fields
  517. $data = array();
  518. $data['duplicates'] = 'new';
  519. foreach ($headings as $idx => $h) {
  520. $csv_heading = trim($headings[$idx]);
  521.  
  522. $found_col = $this->_importColGuess($csv_heading);
  523.  
  524. if (!$found_col) {
  525. foreach ($db_columns as $col => $name) {
  526. if (strcasecmp($col, $csv_heading) == 0) { $found_col = $col; break; }
  527. if (strcasecmp($name, $csv_heading) == 0) { $found_col = $col; break; }
  528. }
  529. }
  530.  
  531. if ($found_col) {
  532. $data['columns'][Enc::httpfield($csv_heading)] = $found_col;
  533. }
  534. }
  535.  
  536. // Replace the pre-filled values with session values if found
  537. if (@count($_SESSION['admin']['field_values']) > 0) {
  538. $data = $_SESSION['admin']['field_values'];
  539. unset ($_SESSION['admin']['field_values']);
  540. }
  541.  
  542. // Prepare the view
  543. try {
  544. $view = new View("sprout/admin/{$this->controller_name}_import");
  545. } catch (Exception $ex) {
  546. $view = new View("sprout/admin/generic_import");
  547. }
  548. $view->controller_name = $this->controller_name;
  549. $view->friendly_name = $this->friendly_name;
  550. $view->headings = $headings;
  551. $view->sample = $sample;
  552. $view->import_columns = $db_columns;
  553. $view->data = $data;
  554. $view->duplicate_options = ($this->import_duplicates == '');
  555. $view->extra_options = $this->_importExtraOptions();
  556.  
  557. $title = 'Import ' . Enc::html(strtolower($this->friendly_name));
  558.  
  559. return array(
  560. 'title' => $title,
  561. 'content' => $view->render(),
  562. );
  563. }
  564.  
  565.  
  566. /**
  567.   * Does the actual import
  568.   *
  569.   * @param string $filename The location of the import data, in a temporary directory
  570.   **/
  571. public function _importData($filename)
  572. {
  573. $_SESSION['admin']['field_values'] = Validator::trim($_POST);
  574.  
  575. $csv = new ImportCSV($filename, $this->import_headings);
  576. $headings = $csv->getHeadings();
  577.  
  578. $real_from_post = array();
  579. foreach ($headings as $name) {
  580. $real_from_post[Enc::httpfield($name)] = $name;
  581. }
  582.  
  583. if ($this->import_duplicates) {
  584. $_POST['duplicates'] = $this->import_duplicates;
  585. }
  586.  
  587. $error = false;
  588. $valid = new Validator($_POST);
  589. $valid->required(['duplicates']);
  590.  
  591. if ($_POST['duplicates'] != 'new') {
  592. $valid->required(['match_field']);
  593. }
  594.  
  595. if (@count($_POST['columns']) == 0) {
  596. Notification::error ('No column mappings defined');
  597. $error = true;
  598.  
  599. } else {
  600. $_POST['columns']['id'] = 'id';
  601. $match_csv = null;
  602. foreach ($_POST['columns'] as $csv_name => $db_name) {
  603. if (isset($real_from_post[$csv_name])) {
  604. $csv_name = $real_from_post[$csv_name];
  605. if ($db_name == @$_POST['match_field']) {
  606. $match_csv = $csv_name;
  607. $match_db = $db_name;
  608. break;
  609. }
  610. }
  611. }
  612.  
  613. if (!$match_csv and $_POST['duplicates'] != 'new') {
  614. Notification::error ('Field used for duplicate matching does not have a column mapping defined');
  615. $error = true;
  616. }
  617. }
  618.  
  619. if ($valid->hasErrors()) {
  620. $_SESSION['admin']['field_errors'] = $valid->getFieldErrors();
  621. $valid->createNotifications();
  622. $error = true;
  623. }
  624.  
  625. if ($error) return false;
  626.  
  627. Pdb::transact();
  628.  
  629. $res = $this->_importPre();
  630. if (! $res) return false;
  631.  
  632. while ($line = $csv->getNamedLine()) {
  633. // Ignore completely blank lines
  634. $blank = true;
  635. foreach ($line as $field) {
  636. if (trim($field)) {
  637. $blank = false;
  638. break;
  639. }
  640. }
  641. if ($blank) continue;
  642.  
  643. // Look for a duplicate
  644. $is_duplicate = false;
  645. $existing_record = false;
  646. if ($_POST['duplicates'] != 'new') {
  647. Pdb::validateIdentifier($match_db);
  648. $q = "SELECT *
  649. FROM ~{$this->table_name}
  650. WHERE {$match_db} = ? ORDER BY id";
  651. try {
  652. $existing_record = Pdb::q($q, [$line[$match_csv]], 'row');
  653. $is_duplicate = true;
  654. } catch (RowMissingException $ex) {
  655. // No problem
  656. }
  657. }
  658.  
  659. // Prepare the field values
  660. $field_values = array();
  661. $new_data = array();
  662. foreach ($_POST['columns'] as $csv_name => $db_name) {
  663. if ($db_name == null) continue;
  664. if (!isset($real_from_post[$csv_name])) continue;
  665.  
  666. $csv_name = $real_from_post[$csv_name];
  667. $new_data[$db_name] = trim($line[$csv_name]);
  668. }
  669.  
  670. // Do pre-import processing
  671. $res = $this->_importPreRecord($new_data, $line);
  672. if (! $res) continue;
  673.  
  674. // Prepare data for insert/update
  675. foreach ($new_data as $key => $val) {
  676. $field_values[$key] = $val;
  677. }
  678.  
  679. // Kill off the id column
  680. if (! $this->import_id_column) {
  681. unset ($field_values['id']);
  682. }
  683.  
  684.  
  685. if ($is_duplicate) {
  686. // Has a duplicate record, do the appropriate action
  687. switch ($_POST['duplicates']) {
  688. case 'new':
  689. $field_values['date_added'] = Pdb::now();
  690. $field_values['date_modified'] = Pdb::now();
  691. $record_id = Pdb::insert($this->table_name, $field_values);
  692. $type = 'insert';
  693. break;
  694.  
  695. case 'merge_blank':
  696. foreach ($field_values as $col => $val) {
  697. if ($val == '' or $val == 'NULL' or $val == "''") {
  698. unset ($field_values[$col]);
  699. }
  700. }
  701. if (@count($field_values) == 0) continue 2;
  702. // fall-through
  703.  
  704. case 'merge':
  705. $field_values['date_modified'] = Pdb::now();
  706. Pdb::update($this->table_name, $field_values, ['id' => $existing_record['id']]);
  707. $record_id = $existing_record['id'];
  708. $type = 'update';
  709. break;
  710.  
  711. case 'skip':
  712. continue 2;
  713.  
  714. }
  715.  
  716. } else {
  717. // No dupe, just do an insert
  718. $field_values['date_added'] = Pdb::now();
  719. $field_values['date_modified'] = Pdb::now();
  720. $record_id = Pdb::insert($this->table_name, $field_values);
  721. $type = 'insert';
  722. }
  723.  
  724. // Do post-import processing
  725. $res = $this->_importPostRecord($record_id, $new_data, $existing_record, $type, $line);
  726. if (! $res) return false;
  727. }
  728.  
  729. $res = $this->_importPost();
  730. if (! $res) return false;
  731.  
  732. Pdb::commit();
  733.  
  734. return true;
  735. }
  736.  
  737.  
  738. /**
  739.   * Try to guess the database name for a given CSV heading.
  740.   * If you can't figure it out, return NULL.
  741.   * If NULL is returned, the rudimentry almost-exact guesser will be run.
  742.   *
  743.   * @param string $csv_heading The exact heading provided in the CSV file.
  744.   * @return string The database field name to use. Must exactly match the database field name.
  745.   **/
  746. protected function _importColGuess($csv_heading) { return null; }
  747.  
  748.  
  749. /**
  750.   * Called when the import form is being built.
  751.   *
  752.   * Returns HTML of extra options to display, or null if no extra options.
  753.   **/
  754. protected function _importExtraOptions () { return null; }
  755.  
  756.  
  757. /**
  758.   * Called at the beginning of the the import process.
  759.   * Is called from within a transaction.
  760.   * Return FALSE to abort the import.
  761.   **/
  762. protected function _importPre() { return true; }
  763.  
  764.  
  765. /**
  766.   * Called after the field data has been determined, but before the insert or update is run.
  767.   *
  768.   * Return FALSE to skip the record.
  769.   *
  770.   * @param array $new_data The CSV data, with database-mapped names, but before
  771.   * the database quoting has happened.
  772.   * This is a by-reference argument.
  773.   * @param array $raw_data Raw CSV data, with original field names.
  774.   **/
  775. protected function _importPreRecord(&$new_data, $raw_data) { return true; }
  776.  
  777.  
  778. /**
  779.   * Called after a record has been inserted or updated.
  780.   *
  781.   * @param int $record_id The id of the record that was inserted or updated.
  782.   * @param array $new_data The new data of the record.
  783.   * @param array $existing_record The old data of the record, which has now been replaced.
  784.   * @param string $type One of 'insert' or 'update'
  785.   * @param array $raw_data Raw CSV data, with original field names.
  786.   * @return boolean False if any errors are encountered; will cancel the entire import process.
  787.   **/
  788. protected function _importPostRecord ($record_id, $new_data, $existing_record, $type, $raw_data) { return true; }
  789.  
  790.  
  791. /**
  792.   * Called at the end of the the import process, after everything has been done.
  793.   * Is called from within a transaction.
  794.   * Return FALSE to abort the import.
  795.   **/
  796. protected function _importPost() { return true; }
  797.  
  798.  
  799. /**
  800.   * Return the WHERE clause to use for a given key which is provided by the RefineBar
  801.   * This must be called in the extending class if no clause can be determined,
  802.   * i.e. return parent::_getRefineClause()
  803.   *
  804.   * Allows custom non-table clauses to be added.
  805.   * Is only called for key names which begin with an underscore.
  806.   * The base table is aliased to 'item'.
  807.   *
  808.   * @param string $key The key name, including underscore
  809.   * @param string $val The value which is being refined.
  810.   * @param array &$query_params Parameters to add to the query which will use the WHERE clause
  811.   * @return string WHERE clause, e.g. "item.name LIKE CONCAT('%', ?, '%')", "item.status IN (?, ?, ?)"
  812.   */
  813. protected function _getRefineClause($key, $val, array &$query_params)
  814. {
  815.  
  816. // Some extra logic for the tag search
  817. if ($key == '_all_tag' or $key == '_any_tag') {
  818. $tags = Tags::splitupTags($val);
  819. $tagwhere = implode(',', str_split(str_repeat('?', count($tags))));
  820. }
  821.  
  822. if (in_array($key, ['_date_added', '_date_modified'])) {
  823. @list($val, $interval) = preg_split('/\s+/', trim($val));
  824. $val = (int) $val;
  825. $valid_intervals = [
  826. 'MICROSECOND',
  827. 'SECOND',
  828. 'MINUTE',
  829. 'HOUR',
  830. 'DAY',
  831. 'WEEK',
  832. 'MONTH',
  833. 'QUARTER',
  834. 'YEAR',
  835. 'SECOND_MICROSECOND',
  836. 'MINUTE_MICROSECOND',
  837. 'MINUTE_SECOND',
  838. 'HOUR_MICROSECOND',
  839. 'HOUR_SECOND',
  840. 'HOUR_MINUTE',
  841. 'DAY_MICROSECOND',
  842. 'DAY_SECOND',
  843. 'DAY_MINUTE',
  844. 'DAY_HOUR',
  845. 'YEAR_MONTH',
  846. ];
  847. if (!in_array($interval, $valid_intervals)) {
  848. throw new InvalidArgumentException('Invalid interval');
  849. }
  850. }
  851.  
  852. switch ($key) {
  853. case '_date_modified':
  854. $query_params[] = $val;
  855. return "item.date_modified >= DATE_SUB(NOW(), INTERVAL ? {$interval})";
  856.  
  857. case '_date_added':
  858. $query_params[] = $val;
  859. return "item.date_added >= DATE_SUB(NOW(), INTERVAL ? {$interval})";
  860.  
  861. case '_all_tag':
  862. $query_params[] = $tbl;
  863. $query_params = array_merge($query_params, $tags);
  864. return "(SELECT COUNT(id) FROM sprout_tags WHERE record_table = ? AND record_id = item.id AND name IN ({$tagwhere})) = " . count($tags);
  865.  
  866. case '_any_tag':
  867. $query_params[] = $tbl;
  868. $query_params = array_merge($query_params, $tags);
  869. return "(SELECT COUNT(id) FROM sprout_tags WHERE record_table = ? AND record_id = item.id AND name IN ({$tagwhere})) >= 1";
  870.  
  871. }
  872.  
  873. return null;
  874. }
  875.  
  876.  
  877. /**
  878.   * Return HTML for a search form
  879.   **/
  880. public function _getSearchForm()
  881. {
  882. $view = new View("sprout/admin/generic_search");
  883.  
  884. // Build the outer view
  885. $view->controller_name = $this->controller_name;
  886. $view->friendly_name = $this->friendly_name;
  887. $view->refine = $this->refine_bar;
  888. $view = $view->render();
  889.  
  890. return array(
  891. 'title' => 'Search ' . Enc::html($this->friendly_name),
  892. 'content' => $view,
  893. );
  894. }
  895.  
  896.  
  897. /**
  898.   * Returns the SQL query for use by the contents list.
  899.   *
  900.   * The query MUST NOT include a LIMIT clause.
  901.   * The query MUST include a SQL_CALC_FOUND_ROWS clause.
  902.   * The main table SHOULD be aliased to 'item'.
  903.   *
  904.   * @param string $where A where clause to use.
  905.   * Generated based on the specified refine options.
  906.   * @param string $order An order clause to use.
  907.   * @param array $params Params to bind to the query. These will be modified to include per-record permissions
  908.   * @return string A SQL query.
  909.   **/
  910. protected function _getContentsQuery($where, $order, &$params)
  911. {
  912. $joins = '';
  913.  
  914. // Determine if per-record permissions used for this controller
  915. // If so, and there's at least one per-record restriction,
  916. // ensure that records which the user can't access aren't displayed
  917. $restrict = PerRecordPerms::controllerRestricted($this);
  918.  
  919. if ($restrict) {
  920. $has_record_perms = PerRecordPerms::hasRecordPerms($this);
  921.  
  922. if ($has_record_perms) {
  923. array_unshift($params, $this->controller_name);
  924. $joins = "LEFT JOIN ~per_record_permissions AS rec_perm
  925. ON rec_perm.controller = ? AND item.id = rec_perm.item_id";
  926.  
  927. $cat_clause = PerRecordPerms::getCategoryClause();
  928. $cat_clause = substr($cat_clause, 1, -1); // nuke leading and trailing brackets
  929.  
  930. $where .= " AND (operator_categories IS NULL OR {$cat_clause})";
  931. }
  932. }
  933.  
  934. $q = "SELECT SQL_CALC_FOUND_ROWS item.*
  935. FROM ~{$this->table_name} AS item
  936. {$joins}
  937. WHERE {$where}
  938. ORDER BY {$order}";
  939. return $q;
  940. }
  941.  
  942.  
  943. /**
  944.   * Return HTML which represents a list of records for this controller
  945.   **/
  946. public function _getContents()
  947. {
  948. if (empty($_GET['page'])) $_GET['page'] = 1;
  949. $_GET['page'] = (int) $_GET['page'];
  950.  
  951. // Apply filter
  952. list($where, $params) = $this->applyRefineFilter();
  953.  
  954. // Build the where clause
  955. $has_refine = (bool) count($where);
  956. if ($this->main_where) $where = array_merge($where, $this->main_where);
  957. $where = implode(' AND ', $where);
  958. if ($where == '') $where = '1';
  959.  
  960. // Determine record order
  961. $_GET['order'] = preg_replace('/[^_a-z]/', '', @$_GET['order']);
  962. if (!empty($_GET['order'])) {
  963. Pdb::validateIdentifier($_GET['order']);
  964. $order = "item.{$_GET['order']}";
  965. if (@$_GET['dir'] == 'asc' or @$_GET['dir'] == 'desc') {
  966. $order .= ' ' . $_GET['dir'];
  967. } else {
  968. $_GET['dir'] = 'asc';
  969. }
  970.  
  971. } else {
  972. $order = $this->main_order;
  973. preg_match('/(item\.)?([_a-z]+)( asc| desc)?/i', $this->main_order, $matches);
  974. $_GET['order'] = trim($matches[2]);
  975. $_GET['dir'] = trim(isset($matches[3]) ? strtolower($matches[3]) : 'asc');
  976. }
  977.  
  978. // Get the actual records
  979. $offset = $this->records_per_page * ($_GET['page'] - 1);
  980. $q = $this->_getContentsQuery($where, $order, $params);
  981. $q .= " LIMIT {$this->records_per_page} OFFSET {$offset}";
  982. $items = Pdb::q($q, $params, 'arr');
  983.  
  984. // Get the total number of records
  985. $q = "SELECT FOUND_ROWS() AS C";
  986. $total_row_count = Pdb::q($q, [], 'val');
  987.  
  988. // If no mode set, use the session
  989. // If a mode is set and valid, save in the session
  990. if (empty($_GET['main_mode'])) {
  991. $_GET['main_mode'] = @$_SESSION['admin'][$this->controller_name]['main_mode'];
  992. } else if ($this->main_modes[$_GET['main_mode']]) {
  993. $_SESSION['admin'][$this->controller_name]['main_mode'] = $_GET['main_mode'];
  994. }
  995.  
  996. // If no valid mode set, use a default
  997. if (!isset($this->main_modes[$_GET['main_mode']])) {
  998. $_GET['main_mode'] = key($this->main_modes);
  999. }
  1000.  
  1001. // Build the refine bar
  1002. if ($this->refine_bar) {
  1003. $refine = $this->refine_bar->get();
  1004. } else {
  1005. $refine = '';
  1006. }
  1007.  
  1008. // Build the mode selector ui
  1009. if (count($this->main_modes) > 1) {
  1010. $mode_sel = $this->_modeSelector($_GET['main_mode']);
  1011. } else {
  1012. $mode_sel = '';
  1013. }
  1014.  
  1015. // If there is no records, tell the user
  1016. if ($total_row_count == 0) {
  1017. if ($has_refine) {
  1018. $items_view = '<p>No records were found which match the specified refinements.</p>';
  1019. } else {
  1020. $items_view = '<p>No records currently exist in the database.</p>';
  1021. }
  1022. } else {
  1023. $items_view = $this->_getContentsView($items, $_GET['main_mode'], null);
  1024. }
  1025.  
  1026. // Build the pagination bar
  1027. if ($total_row_count > $this->records_per_page) {
  1028. $paginate = $this->_paginationBar($_GET['page'], $total_row_count);
  1029. } else {
  1030. $paginate = '';
  1031. }
  1032.  
  1033. return array(
  1034. 'title' => Enc::html($this->friendly_name),
  1035. 'content' => $refine . $mode_sel . $items_view . $paginate,
  1036. );
  1037. }
  1038.  
  1039.  
  1040. /**
  1041.   * Return HTML for a resultset of items
  1042.   * The returned HTML will be sandwiched between the refinebar and the pagination bar.
  1043.   *
  1044.   * @param Traversable $items The items to render.
  1045.   * @param string $mode The mode of the display.
  1046.   * @param anything $unused Not used in this controller, but used by has_categories
  1047.   **/
  1048. public function _getContentsView($items, $mode, $unused)
  1049. {
  1050. return $this->_getContentsViewList($items, $unused);
  1051. }
  1052.  
  1053.  
  1054. /**
  1055.   * Formats a resultset of items into an Itemlist
  1056.   *
  1057.   * @param Traversable $items The items to render.
  1058.   * @param anything $unused Not used in this controller, but used by has_categories
  1059.   **/
  1060. public function _getContentsViewList($items, $unused)
  1061. {
  1062. // Create the itemlist
  1063. $itemlist = new Itemlist();
  1064. $itemlist->main_columns = $this->main_columns;
  1065. $itemlist->items = $items;
  1066. $itemlist->setCheckboxes(true);
  1067. $itemlist->setOrdering(true);
  1068. $itemlist->setActionsClasses('button button-small');
  1069.  
  1070. // Add the actions
  1071. $itemlist->addAction('edit', "SITE/admin/edit/{$this->controller_name}/%%");
  1072. foreach ($this->main_actions as $name => $url) {
  1073. $itemlist->addAction($name, $url, 'button-grey');
  1074. }
  1075. if ($this->getDuplicateEnabled()) {
  1076. $itemlist->addAction('Duplicate', "SITE/admin/duplicate/{$this->controller_name}/%%", 'button-grey icon-before icon-add');
  1077. }
  1078. if ($this->main_delete) {
  1079. $itemlist->addAction('Delete', "SITE/admin/delete/{$this->controller_name}/%%", 'button button-red icon-before icon-delete');
  1080. }
  1081.  
  1082. // Add classes based on visibility fields
  1083. $visibility = $this->_getVisibilityFields();
  1084. $itemlist->setRowClassesFunc(function($row) use($visibility) {
  1085. $out = '';
  1086. foreach ($visibility as $name => $label) {
  1087. $out .= "main-list--{$name}-{$row[$name]} ";
  1088. }
  1089. return rtrim($out);
  1090. });
  1091.  
  1092. // Prepare view which renders the main content area
  1093. $outer = new View("sprout/admin/generic_itemlist_outer");
  1094.  
  1095. // Build the outer view
  1096. $outer->controller_name = $this->controller_name;
  1097. $outer->friendly_name = $this->friendly_name;
  1098. $outer->itemlist = $itemlist->render();
  1099. $outer->allow_add = $this->main_add;
  1100. $outer->allow_del = $this->main_delete;
  1101.  
  1102. return $outer->render();
  1103. }
  1104.  
  1105.  
  1106. /**
  1107.   * Builds the HTML for showing the navigation through pages in the admin.
  1108.   * This method is FINAL to help keep the user interface consistent.
  1109.   *
  1110.   * @param $current_page The current page. 1-based index.
  1111.   * @param $total_row_count The total number of records in the dataset.
  1112.   * @return HTML for the paginate bar.
  1113.   **/
  1114. final protected function _paginationBar($current_page, $total_row_count) {
  1115. $total_page_count = ceil($total_row_count / $this->records_per_page);
  1116.  
  1117. $paginate = "<div class=\"paginate-bar\">";
  1118.  
  1119. $paginate .= "<p class=\"paginate-bar-total\">{$total_row_count} records</p>";
  1120.  
  1121. $paginate .= "<div class=\"paginate-bar-buttons\">";
  1122.  
  1123. if ($current_page > 1) {
  1124. $url = Url::withoutArgs('page') . 'page=' . ($current_page - 1);
  1125. $paginate .= "<a class=\"paginate-bar-button paginate-bar-previous button button-blue button-small icon-before icon-keyboard_arrow_left\" href=\"{$url}\">Prev</a>";
  1126. }
  1127.  
  1128. $paginate .= "<p class=\"paginate-bar-current-page\">Page {$current_page} of {$total_page_count}</p>";
  1129.  
  1130. if ($current_page < $total_page_count) {
  1131. $url = Url::withoutArgs('page') . 'page=' . ($current_page + 1);
  1132. $paginate .= "<a class=\"paginate-bar-button paginate-bar-next button button-blue button-small icon-after icon-keyboard_arrow_right\" href=\"{$url}\">Next</a>";
  1133. }
  1134.  
  1135. $paginate .= "</div>";
  1136.  
  1137. $paginate .= "</div>";
  1138.  
  1139. return $paginate;
  1140. }
  1141.  
  1142.  
  1143. /**
  1144.   * Returns HTML for a ui component to update the current main view mode
  1145.   **/
  1146. final protected function _modeSelector($current_mode) {
  1147. $base = Url::withoutArgs('main_mode');
  1148.  
  1149. echo '<div class="mode-selector">';
  1150.  
  1151. foreach ($this->main_modes as $key => $val) {
  1152. if ($key == $current_mode) {
  1153. echo '<a href="', $base, 'main_mode=', $key, '" class="button button-orange button-regular button-icon icon-before';
  1154. } else {
  1155. echo '<a href="', $base, 'main_mode=', $key, '" class="button button-grey button-regular button-icon icon-before';
  1156. }
  1157.  
  1158. if (is_array($val)) {
  1159. list ($label, $icon) = $val;
  1160.  
  1161. // Set the icon using the icon font class
  1162. if ($icon === "list") {
  1163. $icon = "view_list";
  1164. } else if ($icon === "grid") {
  1165. $icon = "view_module";
  1166. }
  1167.  
  1168. echo ' icon-', $icon, '" title="', Enc::html($label), '"><span class="-vis-hidden">' . Enc::html($label) . "</span>";
  1169.  
  1170. } else {
  1171. // Not an array? assume no icon
  1172. echo '"><span>' . Enc::html($val) . '</span>';
  1173. }
  1174.  
  1175. echo '</a>';
  1176. }
  1177.  
  1178. echo '</div>';
  1179. return ob_get_clean();
  1180. }
  1181.  
  1182.  
  1183. /**
  1184.   * Returns a page title and HTML for a form to add a record
  1185.   * @return array Two elements: 'title' and 'content'
  1186.   */
  1187. public function _getAddForm()
  1188. {
  1189. if (is_array($this->add_defaults)) {
  1190. $data = $this->add_defaults;
  1191. } else {
  1192. $data = [];
  1193. }
  1194.  
  1195. if (@count($_SESSION['admin']['field_values']) > 0) {
  1196. $data = $_SESSION['admin']['field_values'];
  1197. unset($_SESSION['admin']['field_values']);
  1198. }
  1199.  
  1200. $errors = [];
  1201. if (@count($_SESSION['admin']['field_errors']) > 0) {
  1202. $errors = $_SESSION['admin']['field_errors'];
  1203. unset($_SESSION['admin']['field_errors']);
  1204. }
  1205.  
  1206. // Auto-generate form from JSON where possible
  1207. $conf = false;
  1208. try {
  1209. $conf = $this->loadEditJson();
  1210. $view = new View('sprout/auto_edit');
  1211. $view->id = 0;
  1212. $view->config = $conf;
  1213.  
  1214. } catch (FileMissingException $ex) {
  1215. $view_dir = $this->getModulePath();
  1216. $view = new View("{$view_dir}/admin/{$this->controller_name}_add");
  1217. }
  1218.  
  1219. $view->controller_name = $this->controller_name;
  1220. $view->friendly_name = $this->friendly_name;
  1221. $view->data = $data;
  1222. $view->errors = $errors;
  1223.  
  1224. $this->_addPreRender($view);
  1225.  
  1226. return array(
  1227. 'title' => 'Adding ' . Enc::html(Inflector::singular($this->friendly_name)),
  1228. 'content' => $view->render()
  1229. );
  1230. }
  1231.  
  1232.  
  1233. /**
  1234.   * Is the "add" action saved?
  1235.   * These may be false if the UI provides its own save mechanism (e.g. multi-add)
  1236.   *
  1237.   * @return bool True if they are saved, false if they are not
  1238.   */
  1239. public function _isAddSaved()
  1240. {
  1241. return true;
  1242. }
  1243.  
  1244.  
  1245. /**
  1246.   * Optional custom HTML for the save box
  1247.   * Return NULL to use the default HTML
  1248.   *
  1249.   * @param return string HTML
  1250.   */
  1251. public function _getCustomAddSaveHTML()
  1252. {
  1253. return null;
  1254. }
  1255.  
  1256.  
  1257. /**
  1258.   * Return the fields to show in the sidebar when adding or editing a record.
  1259.   * These fields are shown under a heading of "Visibility"
  1260.   *
  1261.   * Key is the field name, value is the field label
  1262.   *
  1263.   * @return array
  1264.   */
  1265. public function _getVisibilityFields()
  1266. {
  1267. return [
  1268. 'active' => 'Active',
  1269. ];
  1270. }
  1271.  
  1272.  
  1273. /**
  1274.   * Inject the visiblity fields into a loaded json configuration, so they actually save
  1275.   *
  1276.   * @param array $conf JSON add/edit configuration
  1277.   */
  1278. protected function injectVisiblityFields(array &$conf)
  1279. {
  1280. $conf['_visibility'] = [];
  1281.  
  1282. $visibility = $this->_getVisibilityFields();
  1283. foreach ($visibility as $name => $label) {
  1284. $conf['_visibility'][] = ['field' => [
  1285. 'name' => $name,
  1286. 'label' => $label,
  1287. ]];
  1288. }
  1289.  
  1290. if ($this->per_subsite) {
  1291. $conf['_visibility'][] = ['field' => [
  1292. 'name' => 'subsite_id',
  1293. 'label' => 'Subsite',
  1294. 'empty' => null,
  1295. ]];
  1296. }
  1297. }
  1298.  
  1299.  
  1300. /**
  1301.   * Return the sub-actions for adding a record (e.g. preview)
  1302.   * These are rendered into HTML using {@see AdminController::renderSubActions}
  1303.   *
  1304.   * @return array
  1305.   */
  1306. public function _getAddSubActions()
  1307. {
  1308. return [];
  1309. }
  1310.  
  1311.  
  1312. /**
  1313.   * Hook called by _getAddForm() just before the view is rendered
  1314.   *
  1315.   * @tag api
  1316.   * @tag module-api
  1317.   **/
  1318. protected function _addPreRender($view) {}
  1319.  
  1320.  
  1321. protected function _preSave($id, &$data)
  1322. {
  1323. if ($id == 0) {
  1324. $data['date_added'] = Pdb::now();
  1325. }
  1326. $data['date_modified'] = Pdb::now();
  1327. }
  1328.  
  1329. /**
  1330.   * Process the saving of an add.
  1331.   *
  1332.   * @param int $item_id The new record id should be returned in this variable
  1333.   * @return boolean True on success, false on failure
  1334.   */
  1335. public function _addSave(&$item_id)
  1336. {
  1337.  
  1338. // Auto-process form using JSON config
  1339. $conf = $this->loadEditJson();
  1340. $this->injectVisiblityFields($conf);
  1341. return $this->saveJsonData($conf, $item_id);
  1342. }
  1343.  
  1344.  
  1345. /**
  1346.   * Returns a page title and HTML for a form to edit a record
  1347.   *
  1348.   * @param int $id The id of the record to get the edit form of
  1349.   * @return array Two elements, 'title' and 'content'
  1350.   */
  1351. public function _getEditForm($id)
  1352. {
  1353. $id = (int) $id;
  1354. if ($id <= 0) throw new InvalidArgumentException('$id must be greater than 0');
  1355.  
  1356. // Get the item
  1357. $q = "SELECT * FROM ~{$this->table_name} WHERE id = ?";
  1358. try {
  1359. $item = Pdb::q($q, [$id], 'row');
  1360. $data = $item;
  1361. } catch (RowMissingException $ex) {
  1362. $single = Inflector::singular($this->friendly_name);
  1363. return new AdminError("Invalid id specified - {$single} does not exist");
  1364. }
  1365.  
  1366. // Auto-generate form from JSON where possible
  1367. $conf = false;
  1368. try {
  1369. $conf = $this->loadEditJson();
  1370. $view = new View('sprout/auto_edit');
  1371. $view->config = $conf;
  1372.  
  1373. $default_link = Inflector::singular($this->table_name) . '_id';
  1374. $data = array_merge($data, JsonForm::loadMultiEditData($conf, $default_link, $id, []));
  1375. $data = array_merge($data, JsonForm::loadAutofillListData($conf, $this->table_name, $id, []));
  1376. } catch (FileMissingException $ex) {
  1377. $view_dir = $this->getModulePath();
  1378. $view = new View("{$view_dir}/admin/{$this->controller_name}_edit");
  1379. }
  1380.  
  1381. // Overlay session data
  1382. if (@count($_SESSION['admin']['field_values']) > 0) {
  1383. $data = $_SESSION['admin']['field_values'];
  1384. unset($_SESSION['admin']['field_values']);
  1385. }
  1386.  
  1387. $errors = [];
  1388. if (@count($_SESSION['admin']['field_errors']) > 0) {
  1389. $errors = $_SESSION['admin']['field_errors'];
  1390. unset($_SESSION['admin']['field_errors']);
  1391. }
  1392.  
  1393. $view->controller_name = $this->controller_name;
  1394. $view->friendly_name = $this->friendly_name;
  1395. $view->id = $id;
  1396. $view->data = $data;
  1397. $view->errors = $errors;
  1398.  
  1399. $this->_editPreRender($view, $id);
  1400.  
  1401. $title = 'Editing ' . Enc::html(Inflector::singular($this->friendly_name));
  1402. return array(
  1403. 'title' => $title . ' <strong>' . Enc::html($this->_identifier($item)) . '</strong>',
  1404. 'content' => $view->render()
  1405. );
  1406. }
  1407.  
  1408.  
  1409. /**
  1410.   * Is the "edit" action saved?
  1411.   * These may be false if the UI provides its own save mechanism
  1412.   *
  1413.   * @return bool True if they are saved, false if they are not
  1414.   */
  1415. public function _isEditSaved($item_id)
  1416. {
  1417. return true;
  1418. }
  1419.  
  1420.  
  1421. /**
  1422.   * Optional custom HTML for the save box
  1423.   * Return NULL to use the default HTML
  1424.   *
  1425.   * @param return string HTML
  1426.   */
  1427. public function _getCustomEditSaveHTML($item_id)
  1428. {
  1429. return null;
  1430. }
  1431.  
  1432.  
  1433. /**
  1434.   * Return the sub-actions for editing a record (e.g. deleting)
  1435.   * These are rendered into HTML using {@see AdminController::renderSubActions}
  1436.   *
  1437.   * @return array Each key is a unique reference to the action, e.g. 'delete', and the value is an array, with keys:
  1438.   * url => URL to link to, e.g. "admin/delete/thing/$item_id"
  1439.   * name => Label to display to the user, e.g. 'Delete'
  1440.   * class => CSS class(es) for the icon, e.g. 'icon-link-button icon-before icon-delete'
  1441.   * new_tab => True to show in new window/tab (optional; defaults to false)
  1442.   */
  1443. public function _getEditSubActions($item_id)
  1444. {
  1445. $actions = [];
  1446.  
  1447. if ($this->_isDeleteSaved($item_id)) {
  1448. $actions['delete'] = [
  1449. 'url' => 'admin/delete/' . $this->controller_name . '/' . $item_id,
  1450. 'name' => 'Delete',
  1451. 'class' => 'icon-link-button icon-before icon-delete',
  1452. ];
  1453. }
  1454.  
  1455. return $actions;
  1456. }
  1457.  
  1458.  
  1459. /**
  1460.   * Return the URL to use for the 'view live site' button, when editing a given record
  1461.   *
  1462.   * @param int $item_id Record which is being editied
  1463.   * @return string URL, either absolute or relative
  1464.   * @return null Default url should be used
  1465.   */
  1466. public function _getEditLiveUrl($item_id)
  1467. {
  1468. return null;
  1469. }
  1470.  
  1471.  
  1472. /**
  1473.   * Hook called by _getEditForm() just before the view is rendered
  1474.   *
  1475.   * @tag api
  1476.   * @tag module-api
  1477.   **/
  1478. protected function _editPreRender($view, $item_id) {}
  1479.  
  1480.  
  1481. /**
  1482.   * Process the saving of a record.
  1483.   *
  1484.   * @param int $item_id The ID of the record to save the data into
  1485.   * @return boolean True on success, false on failure
  1486.   */
  1487. public function _editSave($item_id)
  1488. {
  1489. $item_id = (int) $item_id;
  1490. if ($item_id <= 0) throw new InvalidArgumentException('$item_id must be greater than 0');
  1491.  
  1492. // Auto-process form using JSON config
  1493. $conf = $this->loadEditJson();
  1494. $this->injectVisiblityFields($conf);
  1495. return $this->saveJsonData($conf, $item_id);
  1496. }
  1497.  
  1498.  
  1499. /**
  1500.   * Optional custom HTML for the save box
  1501.   * Return NULL to use the default HTML
  1502.   *
  1503.   * @param return string HTML
  1504.   */
  1505. public function _getCustomDuplicateSaveHTML($item_id)
  1506. {
  1507. return null;
  1508. }
  1509.  
  1510.  
  1511. /**
  1512.   * Return the sub-actions for duplicating a record
  1513.   * These are rendered into HTML using {@see AdminController::renderSubActions}
  1514.   *
  1515.   * @return array
  1516.   */
  1517. public function _getDuplicateSubActions($item_id)
  1518. {
  1519. return [];
  1520. }
  1521.  
  1522.  
  1523. /**
  1524.   * Return HTML which represents the form for duplicating a record
  1525.   *
  1526.   * @param int $id The id of the record to get the original data from
  1527.   **/
  1528. public function _getDuplicateForm($id)
  1529. {
  1530. $id = (int) $id;
  1531. if ($id <= 0) throw new InvalidArgumentException('$id must be greater than 0');
  1532.  
  1533. // Get the item
  1534. $data = $item = Pdb::get($this->table_name, $id);
  1535.  
  1536. // Clobber duplication fields with any defaults defined in controller
  1537. if (@count($this->duplicate_defaults)) {
  1538. foreach ($this->duplicate_defaults as $key => $val) {
  1539. $data[$key] = $val;
  1540. }
  1541. }
  1542.  
  1543. if (@count($_SESSION['admin']['field_values']) > 0) {
  1544. $data = $_SESSION['admin']['field_values'];
  1545. unset ($_SESSION['admin']['field_values']);
  1546. }
  1547.  
  1548. $errors = [];
  1549. if (@count($_SESSION['admin']['field_errors']) > 0) {
  1550. $errors = $_SESSION['admin']['field_errors'];
  1551. unset($_SESSION['admin']['field_errors']);
  1552. }
  1553.  
  1554. // Auto-generate form from JSON where possible
  1555. $conf = false;
  1556. try {
  1557. $conf = $this->loadEditJson();
  1558. $view = new View('sprout/auto_edit');
  1559. $view->config = $conf;
  1560.  
  1561. $default_link = Inflector::singular($this->table_name) . '_id';
  1562. $data = array_merge($data, JsonForm::loadMultiEditData($conf, $default_link, $id, []));
  1563. $data = array_merge($data, JsonForm::loadAutofillListData($conf, $this->table_name, $id, []));
  1564. } catch (FileMissingException $ex) {
  1565. $view_dir = $this->getModulePath();
  1566. $view = new View("{$view_dir}/admin/{$this->controller_name}_edit");
  1567. }
  1568. $view->controller_name = $this->controller_name;
  1569. $view->friendly_name = $this->friendly_name;
  1570. $view->id = $id;
  1571. $view->item = $item;
  1572. $view->data = $data;
  1573. $view->errors = $errors;
  1574.  
  1575. $this->_duplicatePreRender($view, $id);
  1576.  
  1577. $title = 'Duplicating ' . Enc::html(Inflector::singular($this->friendly_name));
  1578. return array(
  1579. 'title' => $title . ' <strong>' . Enc::html($this->_identifier($item)) . '</strong>',
  1580. 'content' => $view->render()
  1581. );
  1582. }
  1583.  
  1584.  
  1585. /**
  1586.   * Hook called by _getDuplicateForm() just before the view is rendered
  1587.   *
  1588.   * @tag api
  1589.   * @tag module-api
  1590.   **/
  1591. protected function _duplicatePreRender($view, $item_id)
  1592. {
  1593. $this->_editPreRender($view, $item_id);
  1594. }
  1595.  
  1596.  
  1597. /**
  1598.   * Process the saving of a duplication. Basic version just calls _editSave
  1599.   *
  1600.   * @param int $id The record to save
  1601.   * @return boolean True on success, false on failure
  1602.   **/
  1603. public function _duplicateSave($id)
  1604. {
  1605. return $this->_editSave($id);
  1606. }
  1607.  
  1608.  
  1609. /**
  1610.   * Return HTML which represents the form for deleting a record
  1611.   *
  1612.   * @param int $id The record to show the delete form for
  1613.   * @return string The HTML code which represents the edit form
  1614.   **/
  1615. public function _getDeleteForm($id)
  1616. {
  1617. $id = (int) $id;
  1618.  
  1619. try {
  1620. $view = new View("{$this->getModulePath()}/admin/{$this->controller_name}_delete");
  1621. } catch (FileMissingException $ex) {
  1622. $view = new View("sprout/admin/generic_delete");
  1623. }
  1624. $view->controller_name = $this->controller_name;
  1625. $view->friendly_name = $this->friendly_name;
  1626. $view->id = $id;
  1627.  
  1628. // Load item details
  1629. try {
  1630. $view->item = Pdb::get($this->table_name, $id);
  1631. } catch (RowMissingException $ex) {
  1632. return [
  1633. 'title' => 'Error',
  1634. 'content' => "Invalid id specified - {$this->controller_name} does not exist",
  1635. ];
  1636. }
  1637.  
  1638. return array(
  1639. 'title' => 'Deleting ' . Enc::html(Inflector::singular($this->friendly_name)) . ' <strong>' . Enc::html($this->_identifier($view->item)) . '</strong>',
  1640. 'content' => $view->render()
  1641. );
  1642. }
  1643.  
  1644.  
  1645. /**
  1646.   * Check if deletion of a particular record is allowed
  1647.   * This method may be overridden if ignoring the $main_delete property is desired
  1648.   * @param int $item_id
  1649.   * @return bool True if they are saved, false if they are not
  1650.   */
  1651. public function _isDeleteSaved($item_id)
  1652. {
  1653. return true;
  1654. }
  1655.  
  1656.  
  1657. /**
  1658.   * Return the sub-actions for deleting a record (e.g. cancel)
  1659.   * These are rendered into HTML using {@see AdminController::renderSubActions}
  1660.   *
  1661.   * @return array
  1662.   */
  1663. public function _getDeleteSubActions($item_id)
  1664. {
  1665. $actions = [];
  1666.  
  1667. $actions['cancel'] = [
  1668. 'url' => 'admin/edit/' . $this->controller_name . '/' . $item_id,
  1669. 'name' => 'Cancel',
  1670. ];
  1671.  
  1672. return $actions;
  1673. }
  1674.  
  1675.  
  1676. /**
  1677.   * Does custom actions before _deleteSave method is called, e.g. extra security checks
  1678.   * @param int $item_id The record to delete
  1679.   * @return void
  1680.   * @throws Exception if the deletion shouldn't proceed for some reason
  1681.   */
  1682. public function _deletePreSave($item_id)
  1683. {
  1684. }
  1685.  
  1686.  
  1687. /**
  1688.   * Does custom actions after the _deleteSave method is called, e.g. clearing cache data
  1689.   * @param int $item_id The record to delete
  1690.   * @return void
  1691.   */
  1692. public function _deletePostSave($item_id)
  1693. {
  1694. }
  1695.  
  1696.  
  1697. /**
  1698.   * Deletes an item and logs the deleted data
  1699.   * @param int $item_id The record to delete
  1700.   * @param bool True on success, false on failure
  1701.   */
  1702. public function _deleteSave($item_id)
  1703. {
  1704. $item_id = (int) $item_id;
  1705.  
  1706. if (!$this->_isDeleteSaved($item_id)) return false;
  1707.  
  1708. $this->deleteRecord($this->table_name, $item_id);
  1709.  
  1710. return true;
  1711. }
  1712.  
  1713.  
  1714. /**
  1715.   * This is called after every add, edit and delete, as well as other (i.e. bulk) actions.
  1716.   * Use it to clear any frontend caches. The default is an empty method.
  1717.   *
  1718.   * @param string $action The name of the action (e.g. 'add', 'edit', 'delete', etc)
  1719.   * @param int $item_id The item which was affected. Bulk actions (e.g. reorders) will have this set to NULL.
  1720.   **/
  1721. public function _invalidateCaches($action, $item_id = null) {}
  1722.  
  1723.  
  1724. /**
  1725.   * Return the navigation for this controller
  1726.   * Should return HTML
  1727.   **/
  1728. abstract public function _getNavigation();
  1729.  
  1730.  
  1731. public function _actionLog()
  1732. {
  1733. return $this->action_log;
  1734. }
  1735.  
  1736.  
  1737. /**
  1738.   * Returns tools to show in the left hand navigation. Return an empty array if no tools.
  1739.   **/
  1740. public function _getTools()
  1741. {
  1742. $friendly = Enc::html(strtolower($this->friendly_name));
  1743.  
  1744. $tools = array();
  1745. $tools['import'] = "<li class=\"import\"><a href=\"SITE/admin/import_upload/{$this->controller_name}\">Import {$friendly}</a></li>";
  1746. $tools['export'] = "<li class=\"export\"><a href=\"SITE/admin/export/{$this->controller_name}\">Export {$friendly}</a></li>";
  1747.  
  1748. if ($this->_actionLog()) {
  1749. $tools['action_log'] = '<li class="action-log"><a href="SITE/admin/contents/action_log?record_table=' . $this->getTableName() . '">View action log</a></li>';
  1750. }
  1751.  
  1752. return $tools;
  1753. }
  1754.  
  1755.  
  1756. /**
  1757.   * Creates the identifier used in the heading, and for reordering.
  1758.   * @param array $item The row being viewed/edited/etc.
  1759.   * @return string
  1760.   */
  1761. public function _identifier(array $item)
  1762. {
  1763. if (isset($item['name'])) return $item['name'];
  1764. if (isset($item['id'])) return "#{$item['id']}";
  1765. return '';
  1766. }
  1767.  
  1768.  
  1769. // This may even be a really bad idea, I haven't decided yet
  1770. /* Optional: _extra_<command>($id) */
  1771.  
  1772.  
  1773. /**
  1774.   * Form to delete multiple records
  1775.   **/
  1776. public function _extraMultiDelete()
  1777. {
  1778. if (! AdminPerms::controllerAccess($this->getControllerName(), 'delete')) {
  1779. return new AdminError('Access denied');
  1780. }
  1781.  
  1782. if (@count($_GET['ids']) == 0) {
  1783. Notification::error('No items selected for deletion');
  1784. Url::redirect('admin/contents/' . $this->controller_name);
  1785. }
  1786.  
  1787. $view = new View('sprout/admin/categories_multi_delete');
  1788. $view->controller_name = $this->controller_name;
  1789. $view->friendly_name = $this->friendly_name;
  1790. $view->ids = $_GET['ids'];
  1791.  
  1792. return $view;
  1793. }
  1794.  
  1795. /**
  1796.   * Delete multiple records
  1797.   **/
  1798. public function postMultiDelete()
  1799. {
  1800. Csrf::checkOrDie();
  1801.  
  1802. if (! AdminPerms::controllerAccess($this->getControllerName(), 'delete')) {
  1803. Notification::error('Access denied');
  1804. Url::redirect('admin/contents/' . $this->controller_name);
  1805. }
  1806.  
  1807. if (@count($_POST['ids']) == 0) {
  1808. Notification::error('No items selected for deletion');
  1809. Url::redirect('admin/contents/' . $this->controller_name);
  1810. }
  1811.  
  1812. $success = 0;
  1813. $constraint = 0;
  1814. foreach ($_POST['ids'] as $item_id) {
  1815. try {
  1816. $res = $this->_deleteSave($item_id);
  1817. if ($res) {
  1818. $success++;
  1819. }
  1820. } catch (ConstraintQueryException $ex) {
  1821. if (Pdb::inTransaction()) {
  1822. Pdb::rollback();
  1823. }
  1824. $constraint++;
  1825. }
  1826. }
  1827.  
  1828. $this->_invalidateCaches('multi_delete');
  1829.  
  1830. if ($success > 0) {
  1831. Notification::confirm('Deletion of ' . $success . ' ' . Inflector::singular($this->getFriendlyName(), $success) . ' was successful');
  1832. }
  1833. if ($constraint > 0) {
  1834. Notification::error($constraint . ' ' . Inflector::singular($this->getFriendlyName(), $constraint) . " in use by other modules and can't be deleted");
  1835. }
  1836.  
  1837. Url::redirect('admin/contents/' . $this->controller_name);
  1838. }
  1839.  
  1840.  
  1841. /**
  1842.   * Multi-tag some items. Uses AJAX. Returns JSON.
  1843.   **/
  1844. public function postJsonMultiTag()
  1845. {
  1846. Csrf::checkOrDie();
  1847.  
  1848. if (! AdminPerms::controllerAccess($this->getControllerName(), 'edit')) {
  1849. Json::error('Access denied');
  1850. }
  1851.  
  1852. if (@count($_POST['ids']) == 0) {
  1853. Json::error('No items selected for tagging');
  1854. }
  1855.  
  1856. $_POST['tags'] = trim($_POST['tags']);
  1857. if ($_POST['tags'] == '') {
  1858. Json::error('No tags entered');
  1859. }
  1860.  
  1861. $new_tags = Tags::splitupTags($_POST['tags']);
  1862. foreach ($_POST['ids'] as $item_id) {
  1863. Tags::update($this->table_name, $item_id, $new_tags, false);
  1864. }
  1865.  
  1866. $this->_invalidateCaches('multi_edit');
  1867.  
  1868. Json::confirm();
  1869. }
  1870.  
  1871. }
  1872.