SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Controllers/Admin/TreeAdminController.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 Sprout\Exceptions\FileMissingException;
  17. use karmabunny\pdb\Exceptions\RowMissingException;
  18. use Sprout\Helpers\AdminAuth;
  19. use Sprout\Helpers\AdminPerms;
  20. use Sprout\Helpers\Csrf;
  21. use Sprout\Helpers\Enc;
  22. use Sprout\Helpers\Inflector;
  23. use Sprout\Helpers\Notification;
  24. use Sprout\Helpers\Pdb;
  25. use Sprout\Helpers\Treenode;
  26. use Sprout\Helpers\Url;
  27. use Sprout\Helpers\View;
  28.  
  29.  
  30. /**
  31. * Any controller which is essentially a tree-based structure of nodes and sub-nodes.
  32. *
  33. * Required fields for a tree controller table:
  34. * id
  35. * name
  36. * parent_id
  37. * record_order
  38. *
  39. * @tag api
  40. * @tag module-api
  41. **/
  42. abstract class TreeAdminController extends ManagedAdminController {
  43.  
  44. /**
  45.   * Constructor
  46.   **/
  47. public function __construct()
  48. {
  49. parent::__construct();
  50.  
  51. $this->add_defaults['parent_id'] = @$_GET['parent_id'];
  52. }
  53.  
  54.  
  55. /**
  56.   * Returns the contents of the navigation pane for the tree
  57.   **/
  58. public function _getNavigation()
  59. {
  60. $nodes_string = '';
  61. if (!empty($_SESSION['admin'][$this->controller_name . '_nav'])) {
  62. $nodes_string = "'" . implode ("', '", $_SESSION['admin'][$this->controller_name . '_nav']) . "'";
  63. }
  64.  
  65. $view = new View('sprout/admin/tree_navigation');
  66. $view->nodes_string = $nodes_string;
  67. $view->controller_name = $this->controller_name;
  68. $view->friendly_name = $this->friendly_name;
  69.  
  70. return $view->render();
  71. }
  72.  
  73.  
  74. /**
  75.   * Returns the tools to show in the left navigation
  76.   **/
  77. public function _getTools()
  78. {
  79. $items = parent::_getTools();
  80.  
  81. if (AdminAuth::isSuper()) {
  82.  
  83. $items[] = "<li class=\"reorder\"><a href=\"admin/call/{$this->controller_name}/reorderTop\" onclick=\"$.facebox({'ajax':this.href}); return false;\">Reorder top-level</a></li>";
  84. }
  85.  
  86. $items[] = "<li class=\"config\"><a href=\"admin/extra/{$this->controller_name}/organise\">Organise tree</a></li>";
  87.  
  88. return $items;
  89. }
  90.  
  91.  
  92. /**
  93.   * Pre-render hook for adding
  94.   **/
  95. protected function _addPreRender($view)
  96. {
  97. parent::_addPreRender($view);
  98.  
  99. $root = Treenode::loadTree($this->table_name);
  100. $view->tree_nodes = $root->getAllChildren();
  101. }
  102.  
  103. /**
  104.   * Pre-render hook for editing
  105.   **/
  106. protected function _editPreRender($view, $item_id)
  107. {
  108. $root = Treenode::loadTree($this->table_name);
  109. $view->tree_nodes = $root->getAllChildren($item_id);
  110. }
  111.  
  112.  
  113. /**
  114.   * Return HTML which represents the form for deleting a record
  115.   *
  116.   * @param int $item_id The record to show the delete form for
  117.   * @return array Two HTML elements with keys 'title' and 'content'
  118.   */
  119. public function _getDeleteForm($item_id)
  120. {
  121. $item_id = (int) $item_id;
  122.  
  123. try {
  124. $view = new View("{$this->getModulePath()}/admin/{$this->controller_name}_delete");
  125. } catch (FileMissingException $ex) {
  126. $view = new View("sprout/admin/tree_delete");
  127. }
  128. $view->controller_name = $this->controller_name;
  129. $view->friendly_name = $this->friendly_name;
  130. $view->id = $item_id;
  131.  
  132. // Load item details
  133. try {
  134. $view->item = Pdb::get($this->table_name, $item_id);
  135. } catch (RowMissingException $ex) {
  136. return [
  137. 'title' => 'Error',
  138. 'content' => "Invalid id specified - {$this->controller_name} does not exist",
  139. ];
  140. }
  141.  
  142. // Child items
  143. $root = Treenode::loadTree($this->table_name);
  144. $node = $root->findNodeValue('id', $item_id);
  145. $children = ($node->children ? $node->children : []);
  146. foreach ($children as $child) {
  147. if (!$child->children) continue;
  148. foreach ($child->children as $descendent) {
  149. $children[] = $descendent;
  150. }
  151. }
  152. $view->children = $children;
  153.  
  154. return [
  155. 'title' => 'Deleting ' . Enc::html(Inflector::singular($this->friendly_name)) . ' <strong>' . Enc::html($this->_identifier($view->item)) . '</strong>',
  156. 'content' => $view->render()
  157. ];
  158. }
  159.  
  160.  
  161. /**
  162.   * Deletes an item and logs the deleted data
  163.   *
  164.   * @param int $item_id The record to delete.
  165.   * @param int $depth Used for recursion.
  166.   * @param int $log_id Log ID referring to deleted parent, if applicable
  167.   * @return bool True on success, false on failure
  168.   */
  169. final public function _deleteSave($item_id, $depth = 0, $log_id = 0)
  170. {
  171. $item_id = (int) $item_id;
  172.  
  173. if (!$this->_isDeleteSaved($item_id)) return false;
  174.  
  175. // Start transaction
  176. if ($depth == 0) {
  177. $extant_transaction = Pdb::inTransaction();
  178. if (!$extant_transaction) Pdb::transact();
  179. }
  180.  
  181. // Delete parent
  182. $parent_log_id = $log_id;
  183. if ($log_id > 0) {
  184. $this->deleteRecord($this->table_name, $item_id, $log_id);
  185. } else if ($depth == 0 and $log_id == 0) {
  186. $parent_log_id = $this->deleteRecord($this->table_name, $item_id);
  187. } else {
  188. $this->deleteRecord($this->table_name, $item_id);
  189. }
  190.  
  191. // Delete children
  192. $q = "SELECT id FROM ~{$this->table_name} WHERE parent_id = ?";
  193. $children = Pdb::q($q, [$item_id], 'col');
  194.  
  195. foreach ($children as $child_id) {
  196. $res = $this->_deleteSave($child_id, $depth + 1, $parent_log_id);
  197. if (! $res) return false;
  198. }
  199.  
  200. // Commit
  201. if ($depth == 0) {
  202. if (!$extant_transaction) Pdb::commit();
  203. }
  204.  
  205. return true;
  206. }
  207.  
  208.  
  209. /**
  210.   * Shows the reorder screen (which is shown in a popup box) for re-ordering the children items
  211.   **/
  212. public function reorder($id)
  213. {
  214. $id = (int) $id;
  215.  
  216. if (! AdminPerms::controllerAccess($this->getControllerName(), 'reorder')) {
  217. echo "<p>Access denied.</p>";
  218. return;
  219. }
  220.  
  221. if ($id == 0) {
  222. echo "<p>Re-ordering of this item is not possible.</p>";
  223. return;
  224. }
  225.  
  226. // Load it
  227. $q = "SELECT * FROM ~{$this->table_name} WHERE id = ?";
  228. try {
  229. $item = Pdb::q($q, [$id], 'row');
  230. } catch (RowMissingException $ex) {
  231. echo "<p>Invalid id specified - item does not exist</p>";
  232. return;
  233. }
  234.  
  235. // Get children
  236. $q = "SELECT id, name
  237. FROM ~{$this->table_name}
  238. WHERE parent_id = ?
  239. ORDER BY record_order";
  240. $children = Pdb::q($q, [$id], 'arr');
  241.  
  242. // If this item does not have any children, use the parent instead
  243. if (count($children) == 0) {
  244. echo $this->reorder($item->parent_id);
  245. return;
  246. }
  247.  
  248. // If this item only has one child, complain that its impossible to re-order
  249. if (count($children) == 1) {
  250. echo "<p>This item does not have enough children for ordering.</p>";
  251. return;
  252. }
  253.  
  254. // View
  255. $view = new View('sprout/admin/categories_reorder');
  256. $view->id = $id;
  257. $view->items = $children;
  258. $view->controller_name = $this->controller_name;
  259. $view->friendly_name = $this->friendly_name;
  260.  
  261. echo $view->render();
  262. }
  263.  
  264.  
  265. /**
  266.   * Shows the reorder screen (which is shown in a popup box) for re-ordering the top-level stuff
  267.   **/
  268. public function reorderTop()
  269. {
  270.  
  271. if (! AdminPerms::controllerAccess($this->getControllerName(), 'reorder')) {
  272. echo "<p>Access denied.</p>";
  273. return;
  274. }
  275.  
  276. // Get children
  277. $q = "SELECT id, name
  278. FROM ~{$this->table_name}
  279. WHERE parent_id = 0
  280. ORDER BY record_order";
  281. $children = Pdb::q($q, [], 'arr');
  282.  
  283. // If there is only one child, complain that its impossible to re-order
  284. if (count($children) == 1) {
  285. echo "<p>This site does not have enough top-level items for ordering.</p>";
  286. return;
  287. }
  288.  
  289. // View
  290. $view = new View('sprout/admin/categories_reorder');
  291. $view->id = 0;
  292. $view->items = $children;
  293. $view->controller_name = $this->controller_name;
  294. $view->friendly_name = $this->friendly_name;
  295.  
  296. echo $view->render();
  297. }
  298.  
  299. /**
  300.   * Saves a tree reorder
  301.   **/
  302. public function reorderSave($parent_id)
  303. {
  304. AdminAuth::checkLogin();
  305. Csrf::checkOrDie();
  306.  
  307. if (! AdminPerms::controllerAccess($this->getControllerName(), 'reorder')) {
  308. Notification::error('Access denied');
  309. Url::redirect('admin/contents/' . $this->getControllerName());
  310. }
  311.  
  312. $parent_id = (int) $parent_id;
  313.  
  314. $record_order = 1;
  315.  
  316. foreach ($_POST['items'] as $id) {
  317. $id = (int) $id;
  318.  
  319. $where = ['id' => $id, 'parent_id' => $parent_id];
  320. Pdb::update($this->table_name, ['record_order' => $record_order], $where);
  321.  
  322. $record_order++;
  323. }
  324.  
  325. $this->_invalidateCaches('reorder');
  326.  
  327. Notification::confirm('Re-order was successful');
  328. Url::redirect("admin/intro/" . $this->controller_name);
  329. }
  330.  
  331.  
  332. /**
  333.   * If the specified item needs a record number to be set,
  334.   * Puts this item at the end of the list.
  335.   *
  336.   * @param int $item_id Record-id to update
  337.   */
  338. protected function fixRecordOrder($item_id)
  339. {
  340. $q = "SELECT record_order, parent_id FROM ~{$this->table_name} WHERE id = ?";
  341. $item = Pdb::q($q, [$item_id], 'row');
  342.  
  343. if ($item['record_order'] != 0) return;
  344.  
  345. $q = "SELECT MAX(record_order) AS m
  346. FROM ~{$this->table_name}
  347. WHERE parent_id = ?";
  348. $order = 1 + Pdb::query($q, [$item['parent_id']], 'val');
  349.  
  350. Pdb::update($this->table_name, ['record_order' => $order], ['id' => $item_id]);
  351. }
  352.  
  353.  
  354. /**
  355.   * Tree organisation tool
  356.   * Bulk renaming, reordering and reparenting
  357.   */
  358. public function _extraOrganise() {
  359. $view = new View('sprout/admin/tree_organise');
  360. $view->root = Treenode::loadTree($this->table_name, ['1'], 'record_order');
  361. $view->controller_name = $this->controller_name;
  362.  
  363. return array(
  364. 'title' => 'Organise ' . Enc::html($this->friendly_name),
  365. 'content' => $view->render()
  366. );
  367. }
  368.  
  369.  
  370. /**
  371.   * Save tree organise form submission, see {@see self::_extraOrganise}
  372.   * @return void Admin will be redirected to a follow-up page
  373.   */
  374. public function organiseAction()
  375. {
  376. Csrf::checkOrDie();
  377.  
  378. $nodes = json_decode($_POST['data'], true);
  379. if (empty($nodes)) {
  380. Notification::error('Failed to read submitted change data');
  381. Url::redirect('admin/extra/' . $this->controller_name . '/organise');
  382. }
  383.  
  384. Pdb::transact();
  385.  
  386. $deletes = [];
  387. foreach ($nodes as $node) {
  388. $node['id'] = (int) @$node['id'];
  389. if (!$node['id']) continue;
  390.  
  391. if (isset($node['deleted']) and $node['deleted'] == 1) {
  392. // Delete
  393. $deletes[] = $node['id'];
  394.  
  395. } else {
  396. $node['parent'] = (int) $node['parent'];
  397. $node['order'] = (int) $node['order'];
  398. $node['name'] = trim($node['name']);
  399.  
  400. // Update existing record
  401. if (!$node['name']) continue;
  402. if (!$node['order']) continue;
  403.  
  404. $update_data = [];
  405. $update_data['name'] = $node['name'];
  406. $update_data['parent_id'] = $node['parent'];
  407. $update_data['record_order'] = $node['order'];
  408. $update_data['date_modified'] = Pdb::now();
  409. Pdb::update($this->table_name, $update_data, ['id' => $node['id']]);
  410. }
  411. }
  412.  
  413. // Deletes are delayed until all other updates
  414. // Depth of 1 prevents a transaction
  415. if (AdminAuth::isSuper()) {
  416. foreach ($deletes as $id) {
  417. $this->_deleteSave($id, 1);
  418. }
  419. }
  420.  
  421. Pdb::commit();
  422.  
  423. Notification::confirm('Your changes have been saved');
  424. Url::redirect('admin/extra/' . $this->controller_name . '/organise');
  425. }
  426.  
  427.  
  428. /**
  429.   * Returns the children for a specific item, in a format required by jqueryFileTree.
  430.   * Uses the POST param 'dir', and is usually run through an AJAX call.
  431.   **/
  432. public function filetreeOpen()
  433. {
  434. $_POST['dir'] = trim($_POST['dir']);
  435. $parent_id = (int) basename($_POST['dir']);
  436.  
  437.  
  438. echo "<ul class=\"jqueryFileTree\" style=\"display: none;\">";
  439.  
  440. // This item
  441. $dir_item_path = preg_replace('!^/(.+)/$!', '$1', $_POST['dir']);
  442. if ($dir_item_path != '/') {
  443. $top_node = Pdb::get($this->table_name, $parent_id);
  444.  
  445. $name = $top_node['name'];
  446. if (strlen($name) > 25) $name = substr($name, 0, 25) . '...';
  447. $name = Enc::html($name);
  448.  
  449. $rel = Enc::html('/' . $dir_item_path);
  450.  
  451. echo "<li class=\"file ext_txt allow-access directory-item\"><a href=\"#\" rel=\"{$rel}\">{$name}</a></li>";
  452. }
  453.  
  454. // Get children
  455. $q = "SELECT child.id, child.name, COUNT(sub.id) AS num_children
  456. FROM ~{$this->table_name} AS child
  457. LEFT JOIN ~{$this->table_name} AS sub ON sub.parent_id = child.id
  458. WHERE child.parent_id = ?
  459. GROUP BY child.id
  460. ORDER BY child.record_order, child.id";
  461. $children = Pdb::q($q, [$parent_id], 'arr');
  462.  
  463. // Children of this item
  464. foreach ($children as $child) {
  465. $name = $child['name'];
  466. if (strlen($name) > 25) $name = substr($name, 0, 25) . '...';
  467. $name = Enc::html($name);
  468.  
  469. $rel = Enc::html($_POST['dir'] . $child['id']);
  470.  
  471. if ($child['num_children'] > 0) {
  472. echo "<li class=\"directory collapsed allow-access\"><a href=\"#\" rel=\"{$rel}/\">{$name}</a></li>";
  473. } else {
  474. echo "<li class=\"file ext_txt allow-access\"><a href=\"#\" rel=\"{$rel}\">{$name}</a></li>";
  475. }
  476. }
  477.  
  478. echo "</ul>";
  479.  
  480. if ($dir_item_path != '/') {
  481. echo "<p class=\"tree-extras\">";
  482. echo "&#43; <a href=\"SITE/admin/add/{$this->controller_name}?parent_id={$parent_id}\">Add Child</a>";
  483. echo " &nbsp; ";
  484. echo "&#8597; <a href=\"SITE/{$this->controller_name}/reorder/{$parent_id}\" onclick=\"$.facebox({'ajax':this.href}); return false;\">Re-order</a>";
  485. echo "</p>";
  486. }
  487.  
  488. if (empty($_SESSION['admin'][$this->controller_name . '_nav'])) {
  489. $_SESSION['admin'][$this->controller_name . '_nav'] = array();
  490. }
  491. if ($_POST['dir'] != '/' and !in_array ($_POST['dir'], $_SESSION['admin'][$this->controller_name . '_nav'])) {
  492. $_SESSION['admin'][$this->controller_name . '_nav'][] = $_POST['dir'];
  493. }
  494. }
  495.  
  496. /**
  497.   * Saves in the session data the currently open items in navigation tree
  498.   * Uses the POST param 'dir', and is usually run through an AJAX call.
  499.   **/
  500. public function filetreeClose()
  501. {
  502. if (empty($_SESSION['admin'][$this->controller_name . '_nav'])) return;
  503.  
  504. $index = array_search ($_POST['dir'], $_SESSION['admin'][$this->controller_name . '_nav']);
  505. unset ($_SESSION['admin'][$this->controller_name . '_nav'][$index]);
  506. }
  507. }
  508.  
  509.  
  510.