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