BookManager.php 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144
  1. <?php
  2. namespace Drupal\book;
  3. use Drupal\Component\Utility\Unicode;
  4. use Drupal\Core\Cache\Cache;
  5. use Drupal\Core\Entity\EntityManagerInterface;
  6. use Drupal\Core\Form\FormStateInterface;
  7. use Drupal\Core\Render\RendererInterface;
  8. use Drupal\Core\Session\AccountInterface;
  9. use Drupal\Core\StringTranslation\TranslationInterface;
  10. use Drupal\Core\StringTranslation\StringTranslationTrait;
  11. use Drupal\Core\Config\ConfigFactoryInterface;
  12. use Drupal\Core\Template\Attribute;
  13. use Drupal\node\NodeInterface;
  14. /**
  15. * Defines a book manager.
  16. */
  17. class BookManager implements BookManagerInterface {
  18. use StringTranslationTrait;
  19. /**
  20. * Defines the maximum supported depth of the book tree.
  21. */
  22. const BOOK_MAX_DEPTH = 9;
  23. /**
  24. * Entity manager Service Object.
  25. *
  26. * @var \Drupal\Core\Entity\EntityManagerInterface
  27. */
  28. protected $entityManager;
  29. /**
  30. * Config Factory Service Object.
  31. *
  32. * @var \Drupal\Core\Config\ConfigFactoryInterface
  33. */
  34. protected $configFactory;
  35. /**
  36. * Books Array.
  37. *
  38. * @var array
  39. */
  40. protected $books;
  41. /**
  42. * Book outline storage.
  43. *
  44. * @var \Drupal\book\BookOutlineStorageInterface
  45. */
  46. protected $bookOutlineStorage;
  47. /**
  48. * Stores flattened book trees.
  49. *
  50. * @var array
  51. */
  52. protected $bookTreeFlattened;
  53. /**
  54. * The renderer.
  55. *
  56. * @var \Drupal\Core\Render\RendererInterface
  57. */
  58. protected $renderer;
  59. /**
  60. * Constructs a BookManager object.
  61. */
  62. public function __construct(EntityManagerInterface $entity_manager, TranslationInterface $translation, ConfigFactoryInterface $config_factory, BookOutlineStorageInterface $book_outline_storage, RendererInterface $renderer) {
  63. $this->entityManager = $entity_manager;
  64. $this->stringTranslation = $translation;
  65. $this->configFactory = $config_factory;
  66. $this->bookOutlineStorage = $book_outline_storage;
  67. $this->renderer = $renderer;
  68. }
  69. /**
  70. * {@inheritdoc}
  71. */
  72. public function getAllBooks() {
  73. if (!isset($this->books)) {
  74. $this->loadBooks();
  75. }
  76. return $this->books;
  77. }
  78. /**
  79. * Loads Books Array.
  80. */
  81. protected function loadBooks() {
  82. $this->books = [];
  83. $nids = $this->bookOutlineStorage->getBooks();
  84. if ($nids) {
  85. $book_links = $this->bookOutlineStorage->loadMultiple($nids);
  86. $nodes = $this->entityManager->getStorage('node')->loadMultiple($nids);
  87. // @todo: Sort by weight and translated title.
  88. // @todo: use route name for links, not system path.
  89. foreach ($book_links as $link) {
  90. $nid = $link['nid'];
  91. if (isset($nodes[$nid]) && $nodes[$nid]->status) {
  92. $link['url'] = $nodes[$nid]->urlInfo();
  93. $link['title'] = $nodes[$nid]->label();
  94. $link['type'] = $nodes[$nid]->bundle();
  95. $this->books[$link['bid']] = $link;
  96. }
  97. }
  98. }
  99. }
  100. /**
  101. * {@inheritdoc}
  102. */
  103. public function getLinkDefaults($nid) {
  104. return [
  105. 'original_bid' => 0,
  106. 'nid' => $nid,
  107. 'bid' => 0,
  108. 'pid' => 0,
  109. 'has_children' => 0,
  110. 'weight' => 0,
  111. 'options' => [],
  112. ];
  113. }
  114. /**
  115. * {@inheritdoc}
  116. */
  117. public function getParentDepthLimit(array $book_link) {
  118. return static::BOOK_MAX_DEPTH - 1 - (($book_link['bid'] && $book_link['has_children']) ? $this->findChildrenRelativeDepth($book_link) : 0);
  119. }
  120. /**
  121. * Determine the relative depth of the children of a given book link.
  122. *
  123. * @param array $book_link
  124. * The book link.
  125. *
  126. * @return int
  127. * The difference between the max depth in the book tree and the depth of
  128. * the passed book link.
  129. */
  130. protected function findChildrenRelativeDepth(array $book_link) {
  131. $max_depth = $this->bookOutlineStorage->getChildRelativeDepth($book_link, static::BOOK_MAX_DEPTH);
  132. return ($max_depth > $book_link['depth']) ? $max_depth - $book_link['depth'] : 0;
  133. }
  134. /**
  135. * {@inheritdoc}
  136. */
  137. public function addFormElements(array $form, FormStateInterface $form_state, NodeInterface $node, AccountInterface $account, $collapsed = TRUE) {
  138. // If the form is being processed during the Ajax callback of our book bid
  139. // dropdown, then $form_state will hold the value that was selected.
  140. if ($form_state->hasValue('book')) {
  141. $node->book = $form_state->getValue('book');
  142. }
  143. $form['book'] = [
  144. '#type' => 'details',
  145. '#title' => $this->t('Book outline'),
  146. '#weight' => 10,
  147. '#open' => !$collapsed,
  148. '#group' => 'advanced',
  149. '#attributes' => [
  150. 'class' => ['book-outline-form'],
  151. ],
  152. '#attached' => [
  153. 'library' => ['book/drupal.book'],
  154. ],
  155. '#tree' => TRUE,
  156. ];
  157. foreach (['nid', 'has_children', 'original_bid', 'parent_depth_limit'] as $key) {
  158. $form['book'][$key] = [
  159. '#type' => 'value',
  160. '#value' => $node->book[$key],
  161. ];
  162. }
  163. $form['book']['pid'] = $this->addParentSelectFormElements($node->book);
  164. // @see \Drupal\book\Form\BookAdminEditForm::bookAdminTableTree(). The
  165. // weight may be larger than 15.
  166. $form['book']['weight'] = [
  167. '#type' => 'weight',
  168. '#title' => $this->t('Weight'),
  169. '#default_value' => $node->book['weight'],
  170. '#delta' => max(15, abs($node->book['weight'])),
  171. '#weight' => 5,
  172. '#description' => $this->t('Pages at a given level are ordered first by weight and then by title.'),
  173. ];
  174. $options = [];
  175. $nid = !$node->isNew() ? $node->id() : 'new';
  176. if ($node->id() && ($nid == $node->book['original_bid']) && ($node->book['parent_depth_limit'] == 0)) {
  177. // This is the top level node in a maximum depth book and thus cannot be
  178. // moved.
  179. $options[$node->id()] = $node->label();
  180. }
  181. else {
  182. foreach ($this->getAllBooks() as $book) {
  183. $options[$book['nid']] = $book['title'];
  184. }
  185. }
  186. if ($account->hasPermission('create new books') && ($nid == 'new' || ($nid != $node->book['original_bid']))) {
  187. // The node can become a new book, if it is not one already.
  188. $options = [$nid => $this->t('- Create a new book -')] + $options;
  189. }
  190. if (!$node->book['bid']) {
  191. // The node is not currently in the hierarchy.
  192. $options = [0 => $this->t('- None -')] + $options;
  193. }
  194. // Add a drop-down to select the destination book.
  195. $form['book']['bid'] = [
  196. '#type' => 'select',
  197. '#title' => $this->t('Book'),
  198. '#default_value' => $node->book['bid'],
  199. '#options' => $options,
  200. '#access' => (bool) $options,
  201. '#description' => $this->t('Your page will be a part of the selected book.'),
  202. '#weight' => -5,
  203. '#attributes' => ['class' => ['book-title-select']],
  204. '#ajax' => [
  205. 'callback' => 'book_form_update',
  206. 'wrapper' => 'edit-book-plid-wrapper',
  207. 'effect' => 'fade',
  208. 'speed' => 'fast',
  209. ],
  210. ];
  211. return $form;
  212. }
  213. /**
  214. * {@inheritdoc}
  215. */
  216. public function checkNodeIsRemovable(NodeInterface $node) {
  217. return (!empty($node->book['bid']) && (($node->book['bid'] != $node->id()) || !$node->book['has_children']));
  218. }
  219. /**
  220. * {@inheritdoc}
  221. */
  222. public function updateOutline(NodeInterface $node) {
  223. if (empty($node->book['bid'])) {
  224. return FALSE;
  225. }
  226. if (!empty($node->book['bid'])) {
  227. if ($node->book['bid'] == 'new') {
  228. // New nodes that are their own book.
  229. $node->book['bid'] = $node->id();
  230. }
  231. elseif (!isset($node->book['original_bid'])) {
  232. $node->book['original_bid'] = $node->book['bid'];
  233. }
  234. }
  235. // Ensure we create a new book link if either the node itself is new, or the
  236. // bid was selected the first time, so that the original_bid is still empty.
  237. $new = empty($node->book['nid']) || empty($node->book['original_bid']);
  238. $node->book['nid'] = $node->id();
  239. // Create a new book from a node.
  240. if ($node->book['bid'] == $node->id()) {
  241. $node->book['pid'] = 0;
  242. }
  243. elseif ($node->book['pid'] < 0) {
  244. // -1 is the default value in BookManager::addParentSelectFormElements().
  245. // The node save should have set the bid equal to the node ID, but
  246. // handle it here if it did not.
  247. $node->book['pid'] = $node->book['bid'];
  248. }
  249. // Prevent changes to the book outline if the node being saved is not the
  250. // default revision.
  251. $updated = FALSE;
  252. if (!$new) {
  253. $original = $this->loadBookLink($node->id(), FALSE);
  254. if ($node->book['bid'] != $original['bid'] || $node->book['pid'] != $original['pid'] || $node->book['weight'] != $original['weight']) {
  255. $updated = TRUE;
  256. }
  257. }
  258. if (($new || $updated) && !$node->isDefaultRevision()) {
  259. return FALSE;
  260. }
  261. return $this->saveBookLink($node->book, $new);
  262. }
  263. /**
  264. * {@inheritdoc}
  265. */
  266. public function getBookParents(array $item, array $parent = []) {
  267. $book = [];
  268. if ($item['pid'] == 0) {
  269. $book['p1'] = $item['nid'];
  270. for ($i = 2; $i <= static::BOOK_MAX_DEPTH; $i++) {
  271. $parent_property = "p$i";
  272. $book[$parent_property] = 0;
  273. }
  274. $book['depth'] = 1;
  275. }
  276. else {
  277. $i = 1;
  278. $book['depth'] = $parent['depth'] + 1;
  279. while ($i < $book['depth']) {
  280. $p = 'p' . $i++;
  281. $book[$p] = $parent[$p];
  282. }
  283. $p = 'p' . $i++;
  284. // The parent (p1 - p9) corresponding to the depth always equals the nid.
  285. $book[$p] = $item['nid'];
  286. while ($i <= static::BOOK_MAX_DEPTH) {
  287. $p = 'p' . $i++;
  288. $book[$p] = 0;
  289. }
  290. }
  291. return $book;
  292. }
  293. /**
  294. * Builds the parent selection form element for the node form or outline tab.
  295. *
  296. * This function is also called when generating a new set of options during
  297. * the Ajax callback, so an array is returned that can be used to replace an
  298. * existing form element.
  299. *
  300. * @param array $book_link
  301. * A fully loaded book link that is part of the book hierarchy.
  302. *
  303. * @return array
  304. * A parent selection form element.
  305. */
  306. protected function addParentSelectFormElements(array $book_link) {
  307. $config = $this->configFactory->get('book.settings');
  308. if ($config->get('override_parent_selector')) {
  309. return [];
  310. }
  311. // Offer a message or a drop-down to choose a different parent page.
  312. $form = [
  313. '#type' => 'hidden',
  314. '#value' => -1,
  315. '#prefix' => '<div id="edit-book-plid-wrapper">',
  316. '#suffix' => '</div>',
  317. ];
  318. if ($book_link['nid'] === $book_link['bid']) {
  319. // This is a book - at the top level.
  320. if ($book_link['original_bid'] === $book_link['bid']) {
  321. $form['#prefix'] .= '<em>' . $this->t('This is the top-level page in this book.') . '</em>';
  322. }
  323. else {
  324. $form['#prefix'] .= '<em>' . $this->t('This will be the top-level page in this book.') . '</em>';
  325. }
  326. }
  327. elseif (!$book_link['bid']) {
  328. $form['#prefix'] .= '<em>' . $this->t('No book selected.') . '</em>';
  329. }
  330. else {
  331. $form = [
  332. '#type' => 'select',
  333. '#title' => $this->t('Parent item'),
  334. '#default_value' => $book_link['pid'],
  335. '#description' => $this->t('The parent page in the book. The maximum depth for a book and all child pages is @maxdepth. Some pages in the selected book may not be available as parents if selecting them would exceed this limit.', ['@maxdepth' => static::BOOK_MAX_DEPTH]),
  336. '#options' => $this->getTableOfContents($book_link['bid'], $book_link['parent_depth_limit'], [$book_link['nid']]),
  337. '#attributes' => ['class' => ['book-title-select']],
  338. '#prefix' => '<div id="edit-book-plid-wrapper">',
  339. '#suffix' => '</div>',
  340. ];
  341. }
  342. $this->renderer->addCacheableDependency($form, $config);
  343. return $form;
  344. }
  345. /**
  346. * Recursively processes and formats book links for getTableOfContents().
  347. *
  348. * This helper function recursively modifies the table of contents array for
  349. * each item in the book tree, ignoring items in the exclude array or at a
  350. * depth greater than the limit. Truncates titles over thirty characters and
  351. * appends an indentation string incremented by depth.
  352. *
  353. * @param array $tree
  354. * The data structure of the book's outline tree. Includes hidden links.
  355. * @param string $indent
  356. * A string appended to each node title. Increments by '--' per depth
  357. * level.
  358. * @param array $toc
  359. * Reference to the table of contents array. This is modified in place, so
  360. * the function does not have a return value.
  361. * @param array $exclude
  362. * Optional array of Node ID values. Any link whose node ID is in this
  363. * array will be excluded (along with its children).
  364. * @param int $depth_limit
  365. * Any link deeper than this value will be excluded (along with its
  366. * children).
  367. */
  368. protected function recurseTableOfContents(array $tree, $indent, array &$toc, array $exclude, $depth_limit) {
  369. $nids = [];
  370. foreach ($tree as $data) {
  371. if ($data['link']['depth'] > $depth_limit) {
  372. // Don't iterate through any links on this level.
  373. return;
  374. }
  375. if (!in_array($data['link']['nid'], $exclude)) {
  376. $nids[] = $data['link']['nid'];
  377. }
  378. }
  379. $nodes = $this->entityManager->getStorage('node')->loadMultiple($nids);
  380. foreach ($tree as $data) {
  381. $nid = $data['link']['nid'];
  382. // Check for excluded or missing node.
  383. if (empty($nodes[$nid])) {
  384. continue;
  385. }
  386. $toc[$nid] = $indent . ' ' . Unicode::truncate($nodes[$nid]->label(), 30, TRUE, TRUE);
  387. if ($data['below']) {
  388. $this->recurseTableOfContents($data['below'], $indent . '--', $toc, $exclude, $depth_limit);
  389. }
  390. }
  391. }
  392. /**
  393. * {@inheritdoc}
  394. */
  395. public function getTableOfContents($bid, $depth_limit, array $exclude = []) {
  396. $tree = $this->bookTreeAllData($bid);
  397. $toc = [];
  398. $this->recurseTableOfContents($tree, '', $toc, $exclude, $depth_limit);
  399. return $toc;
  400. }
  401. /**
  402. * {@inheritdoc}
  403. */
  404. public function deleteFromBook($nid) {
  405. $original = $this->loadBookLink($nid, FALSE);
  406. $this->bookOutlineStorage->delete($nid);
  407. if ($nid == $original['bid']) {
  408. // Handle deletion of a top-level post.
  409. $result = $this->bookOutlineStorage->loadBookChildren($nid);
  410. $children = $this->entityManager->getStorage('node')->loadMultiple(array_keys($result));
  411. foreach ($children as $child) {
  412. $child->book['bid'] = $child->id();
  413. $this->updateOutline($child);
  414. }
  415. }
  416. $this->updateOriginalParent($original);
  417. $this->books = NULL;
  418. Cache::invalidateTags(['bid:' . $original['bid']]);
  419. }
  420. /**
  421. * {@inheritdoc}
  422. */
  423. public function bookTreeAllData($bid, $link = NULL, $max_depth = NULL) {
  424. $tree = &drupal_static(__METHOD__, []);
  425. $language_interface = \Drupal::languageManager()->getCurrentLanguage();
  426. // Use $nid as a flag for whether the data being loaded is for the whole
  427. // tree.
  428. $nid = isset($link['nid']) ? $link['nid'] : 0;
  429. // Generate a cache ID (cid) specific for this $bid, $link, $language, and
  430. // depth.
  431. $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->getId() . ':' . (int) $max_depth;
  432. if (!isset($tree[$cid])) {
  433. // If the tree data was not in the static cache, build $tree_parameters.
  434. $tree_parameters = [
  435. 'min_depth' => 1,
  436. 'max_depth' => $max_depth,
  437. ];
  438. if ($nid) {
  439. $active_trail = $this->getActiveTrailIds($bid, $link);
  440. $tree_parameters['expanded'] = $active_trail;
  441. $tree_parameters['active_trail'] = $active_trail;
  442. $tree_parameters['active_trail'][] = $nid;
  443. }
  444. // Build the tree using the parameters; the resulting tree will be cached.
  445. $tree[$cid] = $this->bookTreeBuild($bid, $tree_parameters);
  446. }
  447. return $tree[$cid];
  448. }
  449. /**
  450. * {@inheritdoc}
  451. */
  452. public function getActiveTrailIds($bid, $link) {
  453. // The tree is for a single item, so we need to match the values in its
  454. // p columns and 0 (the top level) with the plid values of other links.
  455. $active_trail = [0];
  456. for ($i = 1; $i < static::BOOK_MAX_DEPTH; $i++) {
  457. if (!empty($link["p$i"])) {
  458. $active_trail[] = $link["p$i"];
  459. }
  460. }
  461. return $active_trail;
  462. }
  463. /**
  464. * {@inheritdoc}
  465. */
  466. public function bookTreeOutput(array $tree) {
  467. $items = $this->buildItems($tree);
  468. $build = [];
  469. if ($items) {
  470. // Make sure drupal_render() does not re-order the links.
  471. $build['#sorted'] = TRUE;
  472. // Get the book id from the last link.
  473. $item = end($items);
  474. // Add the theme wrapper for outer markup.
  475. // Allow menu-specific theme overrides.
  476. $build['#theme'] = 'book_tree__book_toc_' . $item['original_link']['bid'];
  477. $build['#items'] = $items;
  478. // Set cache tag.
  479. $build['#cache']['tags'][] = 'config:system.book.' . $item['original_link']['bid'];
  480. }
  481. return $build;
  482. }
  483. /**
  484. * Builds the #items property for a book tree's renderable array.
  485. *
  486. * Helper function for ::bookTreeOutput().
  487. *
  488. * @param array $tree
  489. * A data structure representing the tree.
  490. *
  491. * @return array
  492. * The value to use for the #items property of a renderable menu.
  493. */
  494. protected function buildItems(array $tree) {
  495. $items = [];
  496. foreach ($tree as $data) {
  497. $element = [];
  498. // Generally we only deal with visible links, but just in case.
  499. if (!$data['link']['access']) {
  500. continue;
  501. }
  502. // Set a class for the <li> tag. Since $data['below'] may contain local
  503. // tasks, only set 'expanded' to true if the link also has children within
  504. // the current book.
  505. $element['is_expanded'] = FALSE;
  506. $element['is_collapsed'] = FALSE;
  507. if ($data['link']['has_children'] && $data['below']) {
  508. $element['is_expanded'] = TRUE;
  509. }
  510. elseif ($data['link']['has_children']) {
  511. $element['is_collapsed'] = TRUE;
  512. }
  513. // Set a helper variable to indicate whether the link is in the active
  514. // trail.
  515. $element['in_active_trail'] = FALSE;
  516. if ($data['link']['in_active_trail']) {
  517. $element['in_active_trail'] = TRUE;
  518. }
  519. // Allow book-specific theme overrides.
  520. $element['attributes'] = new Attribute();
  521. $element['title'] = $data['link']['title'];
  522. $node = $this->entityManager->getStorage('node')->load($data['link']['nid']);
  523. $element['url'] = $node->urlInfo();
  524. $element['localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : [];
  525. $element['localized_options']['set_active_class'] = TRUE;
  526. $element['below'] = $data['below'] ? $this->buildItems($data['below']) : [];
  527. $element['original_link'] = $data['link'];
  528. // Index using the link's unique nid.
  529. $items[$data['link']['nid']] = $element;
  530. }
  531. return $items;
  532. }
  533. /**
  534. * Builds a book tree, translates links, and checks access.
  535. *
  536. * @param int $bid
  537. * The Book ID to find links for.
  538. * @param array $parameters
  539. * (optional) An associative array of build parameters. Possible keys:
  540. * - expanded: An array of parent link IDs to return only book links that
  541. * are children of one of the parent link IDs in this list. If empty,
  542. * the whole outline is built, unless 'only_active_trail' is TRUE.
  543. * - active_trail: An array of node IDs, representing the currently active
  544. * book link.
  545. * - only_active_trail: Whether to only return links that are in the active
  546. * trail. This option is ignored if 'expanded' is non-empty.
  547. * - min_depth: The minimum depth of book links in the resulting tree.
  548. * Defaults to 1, which is to build the whole tree for the book.
  549. * - max_depth: The maximum depth of book links in the resulting tree.
  550. * - conditions: An associative array of custom database select query
  551. * condition key/value pairs; see
  552. * \Drupal\book\BookOutlineStorage::getBookMenuTree() for the actual
  553. * query.
  554. *
  555. * @return array
  556. * A fully built book tree.
  557. */
  558. protected function bookTreeBuild($bid, array $parameters = []) {
  559. // Build the book tree.
  560. $data = $this->doBookTreeBuild($bid, $parameters);
  561. // Check access for the current user to each item in the tree.
  562. $this->bookTreeCheckAccess($data['tree'], $data['node_links']);
  563. return $data['tree'];
  564. }
  565. /**
  566. * Builds a book tree.
  567. *
  568. * This function may be used build the data for a menu tree only, for example
  569. * to further massage the data manually before further processing happens.
  570. * _menu_tree_check_access() needs to be invoked afterwards.
  571. *
  572. * @param int $bid
  573. * The book ID to find links for.
  574. * @param array $parameters
  575. * (optional) An associative array of build parameters. Possible keys:
  576. * - expanded: An array of parent link IDs to return only book links that
  577. * are children of one of the parent link IDs in this list. If empty,
  578. * the whole outline is built, unless 'only_active_trail' is TRUE.
  579. * - active_trail: An array of node IDs, representing the currently active
  580. * book link.
  581. * - only_active_trail: Whether to only return links that are in the active
  582. * trail. This option is ignored if 'expanded' is non-empty.
  583. * - min_depth: The minimum depth of book links in the resulting tree.
  584. * Defaults to 1, which is to build the whole tree for the book.
  585. * - max_depth: The maximum depth of book links in the resulting tree.
  586. * - conditions: An associative array of custom database select query
  587. * condition key/value pairs; see
  588. * \Drupal\book\BookOutlineStorage::getBookMenuTree() for the actual
  589. * query.
  590. *
  591. * @return array
  592. * An array with links representing the tree structure of the book.
  593. *
  594. * @see \Drupal\book\BookOutlineStorageInterface::getBookMenuTree()
  595. */
  596. protected function doBookTreeBuild($bid, array $parameters = []) {
  597. // Static cache of already built menu trees.
  598. $trees = &drupal_static(__METHOD__, []);
  599. $language_interface = \Drupal::languageManager()->getCurrentLanguage();
  600. // Build the cache id; sort parents to prevent duplicate storage and remove
  601. // default parameter values.
  602. if (isset($parameters['expanded'])) {
  603. sort($parameters['expanded']);
  604. }
  605. $tree_cid = 'book-links:' . $bid . ':tree-data:' . $language_interface->getId() . ':' . hash('sha256', serialize($parameters));
  606. // If we do not have this tree in the static cache, check {cache_data}.
  607. if (!isset($trees[$tree_cid])) {
  608. $cache = \Drupal::cache('data')->get($tree_cid);
  609. if ($cache && $cache->data) {
  610. $trees[$tree_cid] = $cache->data;
  611. }
  612. }
  613. if (!isset($trees[$tree_cid])) {
  614. $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
  615. $result = $this->bookOutlineStorage->getBookMenuTree($bid, $parameters, $min_depth, static::BOOK_MAX_DEPTH);
  616. // Build an ordered array of links using the query result object.
  617. $links = [];
  618. foreach ($result as $link) {
  619. $link = (array) $link;
  620. $links[$link['nid']] = $link;
  621. }
  622. $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : []);
  623. $data['tree'] = $this->buildBookOutlineData($links, $active_trail, $min_depth);
  624. $data['node_links'] = [];
  625. $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);
  626. // Cache the data, if it is not already in the cache.
  627. \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, ['bid:' . $bid]);
  628. $trees[$tree_cid] = $data;
  629. }
  630. return $trees[$tree_cid];
  631. }
  632. /**
  633. * {@inheritdoc}
  634. */
  635. public function bookTreeCollectNodeLinks(&$tree, &$node_links) {
  636. // All book links are nodes.
  637. // @todo clean this up.
  638. foreach ($tree as $key => $v) {
  639. $nid = $v['link']['nid'];
  640. $node_links[$nid][$tree[$key]['link']['nid']] = &$tree[$key]['link'];
  641. $tree[$key]['link']['access'] = FALSE;
  642. if ($tree[$key]['below']) {
  643. $this->bookTreeCollectNodeLinks($tree[$key]['below'], $node_links);
  644. }
  645. }
  646. }
  647. /**
  648. * {@inheritdoc}
  649. */
  650. public function bookTreeGetFlat(array $book_link) {
  651. if (!isset($this->bookTreeFlattened[$book_link['nid']])) {
  652. // Call $this->bookTreeAllData() to take advantage of caching.
  653. $tree = $this->bookTreeAllData($book_link['bid'], $book_link, $book_link['depth'] + 1);
  654. $this->bookTreeFlattened[$book_link['nid']] = [];
  655. $this->flatBookTree($tree, $this->bookTreeFlattened[$book_link['nid']]);
  656. }
  657. return $this->bookTreeFlattened[$book_link['nid']];
  658. }
  659. /**
  660. * Recursively converts a tree of menu links to a flat array.
  661. *
  662. * @param array $tree
  663. * A tree of menu links in an array.
  664. * @param array $flat
  665. * A flat array of the menu links from $tree, passed by reference.
  666. *
  667. * @see static::bookTreeGetFlat()
  668. */
  669. protected function flatBookTree(array $tree, array &$flat) {
  670. foreach ($tree as $data) {
  671. $flat[$data['link']['nid']] = $data['link'];
  672. if ($data['below']) {
  673. $this->flatBookTree($data['below'], $flat);
  674. }
  675. }
  676. }
  677. /**
  678. * {@inheritdoc}
  679. */
  680. public function loadBookLink($nid, $translate = TRUE) {
  681. $links = $this->loadBookLinks([$nid], $translate);
  682. return isset($links[$nid]) ? $links[$nid] : FALSE;
  683. }
  684. /**
  685. * {@inheritdoc}
  686. */
  687. public function loadBookLinks($nids, $translate = TRUE) {
  688. $result = $this->bookOutlineStorage->loadMultiple($nids, $translate);
  689. $links = [];
  690. foreach ($result as $link) {
  691. if ($translate) {
  692. $this->bookLinkTranslate($link);
  693. }
  694. $links[$link['nid']] = $link;
  695. }
  696. return $links;
  697. }
  698. /**
  699. * {@inheritdoc}
  700. */
  701. public function saveBookLink(array $link, $new) {
  702. // Keep track of Book IDs for cache clear.
  703. $affected_bids[$link['bid']] = $link['bid'];
  704. $link += $this->getLinkDefaults($link['nid']);
  705. if ($new) {
  706. // Insert new.
  707. $parents = $this->getBookParents($link, (array) $this->loadBookLink($link['pid'], FALSE));
  708. $this->bookOutlineStorage->insert($link, $parents);
  709. // Update the has_children status of the parent.
  710. $this->updateParent($link);
  711. }
  712. else {
  713. $original = $this->loadBookLink($link['nid'], FALSE);
  714. // Using the Book ID as the key keeps this unique.
  715. $affected_bids[$original['bid']] = $original['bid'];
  716. // Handle links that are moving.
  717. if ($link['bid'] != $original['bid'] || $link['pid'] != $original['pid']) {
  718. // Update the bid for this page and all children.
  719. if ($link['pid'] == 0) {
  720. $link['depth'] = 1;
  721. $parent = [];
  722. }
  723. // In case the form did not specify a proper PID we use the BID as new
  724. // parent.
  725. elseif (($parent_link = $this->loadBookLink($link['pid'], FALSE)) && $parent_link['bid'] != $link['bid']) {
  726. $link['pid'] = $link['bid'];
  727. $parent = $this->loadBookLink($link['pid'], FALSE);
  728. $link['depth'] = $parent['depth'] + 1;
  729. }
  730. else {
  731. $parent = $this->loadBookLink($link['pid'], FALSE);
  732. $link['depth'] = $parent['depth'] + 1;
  733. }
  734. $this->setParents($link, $parent);
  735. $this->moveChildren($link, $original);
  736. // Update the has_children status of the original parent.
  737. $this->updateOriginalParent($original);
  738. // Update the has_children status of the new parent.
  739. $this->updateParent($link);
  740. }
  741. // Update the weight and pid.
  742. $this->bookOutlineStorage->update($link['nid'], [
  743. 'weight' => $link['weight'],
  744. 'pid' => $link['pid'],
  745. 'bid' => $link['bid'],
  746. ]);
  747. }
  748. $cache_tags = [];
  749. foreach ($affected_bids as $bid) {
  750. $cache_tags[] = 'bid:' . $bid;
  751. }
  752. Cache::invalidateTags($cache_tags);
  753. return $link;
  754. }
  755. /**
  756. * Moves children from the original parent to the updated link.
  757. *
  758. * @param array $link
  759. * The link being saved.
  760. * @param array $original
  761. * The original parent of $link.
  762. */
  763. protected function moveChildren(array $link, array $original) {
  764. $p = 'p1';
  765. $expressions = [];
  766. for ($i = 1; $i <= $link['depth']; $p = 'p' . ++$i) {
  767. $expressions[] = [$p, ":p_$i", [":p_$i" => $link[$p]]];
  768. }
  769. $j = $original['depth'] + 1;
  770. while ($i <= static::BOOK_MAX_DEPTH && $j <= static::BOOK_MAX_DEPTH) {
  771. $expressions[] = ['p' . $i++, 'p' . $j++, []];
  772. }
  773. while ($i <= static::BOOK_MAX_DEPTH) {
  774. $expressions[] = ['p' . $i++, 0, []];
  775. }
  776. $shift = $link['depth'] - $original['depth'];
  777. if ($shift > 0) {
  778. // The order of expressions must be reversed so the new values don't
  779. // overwrite the old ones before they can be used because "Single-table
  780. // UPDATE assignments are generally evaluated from left to right"
  781. // @see http://dev.mysql.com/doc/refman/5.0/en/update.html
  782. $expressions = array_reverse($expressions);
  783. }
  784. $this->bookOutlineStorage->updateMovedChildren($link['bid'], $original, $expressions, $shift);
  785. }
  786. /**
  787. * Sets the has_children flag of the parent of the node.
  788. *
  789. * This method is mostly called when a book link is moved/created etc. So we
  790. * want to update the has_children flag of the new parent book link.
  791. *
  792. * @param array $link
  793. * The book link, data reflecting its new position, whose new parent we want
  794. * to update.
  795. *
  796. * @return bool
  797. * TRUE if the update was successful (either there is no parent to update,
  798. * or the parent was updated successfully), FALSE on failure.
  799. */
  800. protected function updateParent(array $link) {
  801. if ($link['pid'] == 0) {
  802. // Nothing to update.
  803. return TRUE;
  804. }
  805. return $this->bookOutlineStorage->update($link['pid'], ['has_children' => 1]);
  806. }
  807. /**
  808. * Updates the has_children flag of the parent of the original node.
  809. *
  810. * This method is called when a book link is moved or deleted. So we want to
  811. * update the has_children flag of the parent node.
  812. *
  813. * @param array $original
  814. * The original link whose parent we want to update.
  815. *
  816. * @return bool
  817. * TRUE if the update was successful (either there was no original parent to
  818. * update, or the original parent was updated successfully), FALSE on
  819. * failure.
  820. */
  821. protected function updateOriginalParent(array $original) {
  822. if ($original['pid'] == 0) {
  823. // There were no parents of this link. Nothing to update.
  824. return TRUE;
  825. }
  826. // Check if $original had at least one child.
  827. $original_number_of_children = $this->bookOutlineStorage->countOriginalLinkChildren($original);
  828. $parent_has_children = ((bool) $original_number_of_children) ? 1 : 0;
  829. // Update the parent. If the original link did not have children, then the
  830. // parent now does not have children. If the original had children, then the
  831. // the parent has children now (still).
  832. return $this->bookOutlineStorage->update($original['pid'], ['has_children' => $parent_has_children]);
  833. }
  834. /**
  835. * Sets the p1 through p9 properties for a book link being saved.
  836. *
  837. * @param array $link
  838. * The book link to update, passed by reference.
  839. * @param array $parent
  840. * The parent values to set.
  841. */
  842. protected function setParents(array &$link, array $parent) {
  843. $i = 1;
  844. while ($i < $link['depth']) {
  845. $p = 'p' . $i++;
  846. $link[$p] = $parent[$p];
  847. }
  848. $p = 'p' . $i++;
  849. // The parent (p1 - p9) corresponding to the depth always equals the nid.
  850. $link[$p] = $link['nid'];
  851. while ($i <= static::BOOK_MAX_DEPTH) {
  852. $p = 'p' . $i++;
  853. $link[$p] = 0;
  854. }
  855. }
  856. /**
  857. * {@inheritdoc}
  858. */
  859. public function bookTreeCheckAccess(&$tree, $node_links = []) {
  860. if ($node_links) {
  861. // @todo Extract that into its own method.
  862. $nids = array_keys($node_links);
  863. // @todo This should be actually filtering on the desired node status
  864. // field language and just fall back to the default language.
  865. $nids = \Drupal::entityQuery('node')
  866. ->condition('nid', $nids, 'IN')
  867. ->condition('status', 1)
  868. ->execute();
  869. foreach ($nids as $nid) {
  870. foreach ($node_links[$nid] as $mlid => $link) {
  871. $node_links[$nid][$mlid]['access'] = TRUE;
  872. }
  873. }
  874. }
  875. $this->doBookTreeCheckAccess($tree);
  876. }
  877. /**
  878. * Sorts the menu tree and recursively checks access for each item.
  879. *
  880. * @param array $tree
  881. * The book tree to operate on.
  882. */
  883. protected function doBookTreeCheckAccess(&$tree) {
  884. $new_tree = [];
  885. foreach ($tree as $key => $v) {
  886. $item = &$tree[$key]['link'];
  887. $this->bookLinkTranslate($item);
  888. if ($item['access']) {
  889. if ($tree[$key]['below']) {
  890. $this->doBookTreeCheckAccess($tree[$key]['below']);
  891. }
  892. // The weights are made a uniform 5 digits by adding 50000 as an offset.
  893. // After calling $this->bookLinkTranslate(), $item['title'] has the
  894. // translated title. Adding the nid to the end of the index insures that
  895. // it is unique.
  896. $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['nid']] = $tree[$key];
  897. }
  898. }
  899. // Sort siblings in the tree based on the weights and localized titles.
  900. ksort($new_tree);
  901. $tree = $new_tree;
  902. }
  903. /**
  904. * {@inheritdoc}
  905. */
  906. public function bookLinkTranslate(&$link) {
  907. $node = NULL;
  908. // Access will already be set in the tree functions.
  909. if (!isset($link['access'])) {
  910. $node = $this->entityManager->getStorage('node')->load($link['nid']);
  911. $link['access'] = $node && $node->access('view');
  912. }
  913. // For performance, don't localize a link the user can't access.
  914. if ($link['access']) {
  915. // @todo - load the nodes en-mass rather than individually.
  916. if (!$node) {
  917. $node = $this->entityManager->getStorage('node')
  918. ->load($link['nid']);
  919. }
  920. // The node label will be the value for the current user's language.
  921. $link['title'] = $node->label();
  922. $link['options'] = [];
  923. }
  924. return $link;
  925. }
  926. /**
  927. * Sorts and returns the built data representing a book tree.
  928. *
  929. * @param array $links
  930. * A flat array of book links that are part of the book. Each array element
  931. * is an associative array of information about the book link, containing
  932. * the fields from the {book} table. This array must be ordered depth-first.
  933. * @param array $parents
  934. * An array of the node ID values that are in the path from the current
  935. * page to the root of the book tree.
  936. * @param int $depth
  937. * The minimum depth to include in the returned book tree.
  938. *
  939. * @return array
  940. * An array of book links in the form of a tree. Each item in the tree is an
  941. * associative array containing:
  942. * - link: The book link item from $links, with additional element
  943. * 'in_active_trail' (TRUE if the link ID was in $parents).
  944. * - below: An array containing the sub-tree of this item, where each
  945. * element is a tree item array with 'link' and 'below' elements. This
  946. * array will be empty if the book link has no items in its sub-tree
  947. * having a depth greater than or equal to $depth.
  948. */
  949. protected function buildBookOutlineData(array $links, array $parents = [], $depth = 1) {
  950. // Reverse the array so we can use the more efficient array_pop() function.
  951. $links = array_reverse($links);
  952. return $this->buildBookOutlineRecursive($links, $parents, $depth);
  953. }
  954. /**
  955. * Builds the data representing a book tree.
  956. *
  957. * The function is a bit complex because the rendering of a link depends on
  958. * the next book link.
  959. *
  960. * @param array $links
  961. * A flat array of book links that are part of the book. Each array element
  962. * is an associative array of information about the book link, containing
  963. * the fields from the {book} table. This array must be ordered depth-first.
  964. * @param array $parents
  965. * An array of the node ID values that are in the path from the current page
  966. * to the root of the book tree.
  967. * @param int $depth
  968. * The minimum depth to include in the returned book tree.
  969. *
  970. * @return array
  971. * Book tree.
  972. */
  973. protected function buildBookOutlineRecursive(&$links, $parents, $depth) {
  974. $tree = [];
  975. while ($item = array_pop($links)) {
  976. // We need to determine if we're on the path to root so we can later build
  977. // the correct active trail.
  978. $item['in_active_trail'] = in_array($item['nid'], $parents);
  979. // Add the current link to the tree.
  980. $tree[$item['nid']] = [
  981. 'link' => $item,
  982. 'below' => [],
  983. ];
  984. // Look ahead to the next link, but leave it on the array so it's
  985. // available to other recursive function calls if we return or build a
  986. // sub-tree.
  987. $next = end($links);
  988. // Check whether the next link is the first in a new sub-tree.
  989. if ($next && $next['depth'] > $depth) {
  990. // Recursively call buildBookOutlineRecursive to build the sub-tree.
  991. $tree[$item['nid']]['below'] = $this->buildBookOutlineRecursive($links, $parents, $next['depth']);
  992. // Fetch next link after filling the sub-tree.
  993. $next = end($links);
  994. }
  995. // Determine if we should exit the loop and $request = return.
  996. if (!$next || $next['depth'] < $depth) {
  997. break;
  998. }
  999. }
  1000. return $tree;
  1001. }
  1002. /**
  1003. * {@inheritdoc}
  1004. */
  1005. public function bookSubtreeData($link) {
  1006. $tree = &drupal_static(__METHOD__, []);
  1007. // Generate a cache ID (cid) specific for this $link.
  1008. $cid = 'book-links:subtree-cid:' . $link['nid'];
  1009. if (!isset($tree[$cid])) {
  1010. $tree_cid_cache = \Drupal::cache('data')->get($cid);
  1011. if ($tree_cid_cache && $tree_cid_cache->data) {
  1012. // If the cache entry exists, it will just be the cid for the actual
  1013. // data. This avoids duplication of large amounts of data.
  1014. $cache = \Drupal::cache('data')->get($tree_cid_cache->data);
  1015. if ($cache && isset($cache->data)) {
  1016. $data = $cache->data;
  1017. }
  1018. }
  1019. // If the subtree data was not in the cache, $data will be NULL.
  1020. if (!isset($data)) {
  1021. $result = $this->bookOutlineStorage->getBookSubtree($link, static::BOOK_MAX_DEPTH);
  1022. $links = [];
  1023. foreach ($result as $item) {
  1024. $links[] = $item;
  1025. }
  1026. $data['tree'] = $this->buildBookOutlineData($links, [], $link['depth']);
  1027. $data['node_links'] = [];
  1028. $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);
  1029. // Compute the real cid for book subtree data.
  1030. $tree_cid = 'book-links:subtree-data:' . hash('sha256', serialize($data));
  1031. // Cache the data, if it is not already in the cache.
  1032. if (!\Drupal::cache('data')->get($tree_cid)) {
  1033. \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, ['bid:' . $link['bid']]);
  1034. }
  1035. // Cache the cid of the (shared) data using the book and item-specific
  1036. // cid.
  1037. \Drupal::cache('data')->set($cid, $tree_cid, Cache::PERMANENT, ['bid:' . $link['bid']]);
  1038. }
  1039. // Check access for the current user to each item in the tree.
  1040. $this->bookTreeCheckAccess($data['tree'], $data['node_links']);
  1041. $tree[$cid] = $data['tree'];
  1042. }
  1043. return $tree[$cid];
  1044. }
  1045. }