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