BookManager.php 36.4 KB
Newer Older
1
<?php
2

3 4 5 6 7 8 9
/**
 * @file
 * Contains \Drupal\book\BookManager.
 */

namespace Drupal\book;

10
use Drupal\Component\Utility\Unicode;
11
use Drupal\Core\Cache\Cache;
12
use Drupal\Core\Entity\EntityManagerInterface;
13
use Drupal\Core\Form\FormStateInterface;
14
use Drupal\Core\Render\RendererInterface;
15 16
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslationInterface;
17
use Drupal\Core\StringTranslation\StringTranslationTrait;
18
use Drupal\Core\Config\ConfigFactoryInterface;
19
use Drupal\Core\Template\Attribute;
20
use Drupal\node\NodeInterface;
21 22

/**
23
 * Defines a book manager.
24
 */
25
class BookManager implements BookManagerInterface {
26
  use StringTranslationTrait;
27 28 29 30 31

  /**
   * Defines the maximum supported depth of the book tree.
   */
  const BOOK_MAX_DEPTH = 9;
32

33 34 35
  /**
   * Entity manager Service Object.
   *
36
   * @var \Drupal\Core\Entity\EntityManagerInterface
37 38 39
   */
  protected $entityManager;

40 41 42
  /**
   * Config Factory Service Object.
   *
43
   * @var \Drupal\Core\Config\ConfigFactoryInterface
44 45 46
   */
  protected $configFactory;

47 48 49 50 51 52 53
  /**
   * Books Array.
   *
   * @var array
   */
  protected $books;

54 55 56 57 58 59 60
  /**
   * Book outline storage.
   *
   * @var \Drupal\book\BookOutlineStorageInterface
   */
  protected $bookOutlineStorage;

61 62 63 64 65 66 67
  /**
   * Stores flattened book trees.
   *
   * @var array
   */
  protected $bookTreeFlattened;

68 69 70 71 72 73 74
  /**
   * The renderer.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

75 76 77
  /**
   * Constructs a BookManager object.
   */
78
  public function __construct(EntityManagerInterface $entity_manager, TranslationInterface $translation, ConfigFactoryInterface $config_factory, BookOutlineStorageInterface $book_outline_storage, RendererInterface $renderer) {
79
    $this->entityManager = $entity_manager;
80
    $this->stringTranslation = $translation;
81
    $this->configFactory = $config_factory;
82
    $this->bookOutlineStorage = $book_outline_storage;
83
    $this->renderer = $renderer;
84 85 86
  }

  /**
87
   * {@inheritdoc}
88 89 90 91 92 93 94 95 96 97 98 99 100
   */
  public function getAllBooks() {
    if (!isset($this->books)) {
      $this->loadBooks();
    }
    return $this->books;
  }

  /**
   * Loads Books Array.
   */
  protected function loadBooks() {
    $this->books = array();
101
    $nids = $this->bookOutlineStorage->getBooks();
102

103
    if ($nids) {
104
      $book_links = $this->bookOutlineStorage->loadMultiple($nids);
105
      $nodes = $this->entityManager->getStorage('node')->loadMultiple($nids);
106
      // @todo: Sort by weight and translated title.
107

108
      // @todo: use route name for links, not system path.
109
      foreach ($book_links as $link) {
110 111
        $nid = $link['nid'];
        if (isset($nodes[$nid]) && $nodes[$nid]->status) {
112
          $link['url'] = $nodes[$nid]->urlInfo();
113 114 115 116
          $link['title'] = $nodes[$nid]->label();
          $link['type'] = $nodes[$nid]->bundle();
          $this->books[$link['bid']] = $link;
        }
117 118 119 120
      }
    }
  }

121
  /**
122
   * {@inheritdoc}
123 124 125 126 127 128
   */
  public function getLinkDefaults($nid) {
    return array(
      'original_bid' => 0,
      'nid' => $nid,
      'bid' => 0,
129
      'pid' => 0,
130 131 132 133 134 135 136
      'has_children' => 0,
      'weight' => 0,
      'options' => array(),
    );
  }

  /**
137
   * {@inheritdoc}
138 139
   */
  public function getParentDepthLimit(array $book_link) {
140
    return static::BOOK_MAX_DEPTH - 1 - (($book_link['bid'] && $book_link['has_children']) ? $this->findChildrenRelativeDepth($book_link) : 0);
141 142 143
  }

  /**
144 145 146 147 148 149 150 151
   * Determine the relative depth of the children of a given book link.
   *
   * @param array
   *   The book link.
   *
   * @return int
   *   The difference between the max depth in the book tree and the depth of
   *   the passed book link.
152
   */
153
  protected function findChildrenRelativeDepth(array $book_link) {
154
    $max_depth = $this->bookOutlineStorage->getChildRelativeDepth($book_link, static::BOOK_MAX_DEPTH);
155
    return ($max_depth > $book_link['depth']) ? $max_depth - $book_link['depth'] : 0;
156 157 158
  }

  /**
159
   * {@inheritdoc}
160
   */
161
  public function addFormElements(array $form, FormStateInterface $form_state, NodeInterface $node, AccountInterface $account, $collapsed = TRUE) {
162 163
    // If the form is being processed during the Ajax callback of our book bid
    // dropdown, then $form_state will hold the value that was selected.
164 165
    if ($form_state->hasValue('book')) {
      $node->book = $form_state->getValue('book');
166 167 168 169 170
    }
    $form['book'] = array(
      '#type' => 'details',
      '#title' => $this->t('Book outline'),
      '#weight' => 10,
171
      '#open' => !$collapsed,
172 173 174 175 176
      '#group' => 'advanced',
      '#attributes' => array(
        'class' => array('book-outline-form'),
      ),
      '#attached' => array(
177
        'library' => array('book/drupal.book'),
178 179 180
      ),
      '#tree' => TRUE,
    );
181
    foreach (array('nid', 'has_children', 'original_bid', 'parent_depth_limit') as $key) {
182 183 184 185 186 187
      $form['book'][$key] = array(
        '#type' => 'value',
        '#value' => $node->book[$key],
      );
    }

188
    $form['book']['pid'] = $this->addParentSelectFormElements($node->book);
189

190 191
    // @see \Drupal\book\Form\BookAdminEditForm::bookAdminTableTree(). The
    // weight may be larger than 15.
192 193 194 195 196 197 198 199 200 201 202
    $form['book']['weight'] = array(
      '#type' => 'weight',
      '#title' => $this->t('Weight'),
      '#default_value' => $node->book['weight'],
      '#delta' => max(15, abs($node->book['weight'])),
      '#weight' => 5,
      '#description' => $this->t('Pages at a given level are ordered first by weight and then by title.'),
    );
    $options = array();
    $nid = !$node->isNew() ? $node->id() : 'new';
    if ($node->id() && ($nid == $node->book['original_bid']) && ($node->book['parent_depth_limit'] == 0)) {
203 204
      // This is the top level node in a maximum depth book and thus cannot be
      // moved.
205 206 207 208 209 210 211 212 213 214 215 216
      $options[$node->id()] = $node->label();
    }
    else {
      foreach ($this->getAllBooks() as $book) {
        $options[$book['nid']] = $book['title'];
      }
    }

    if ($account->hasPermission('create new books') && ($nid == 'new' || ($nid != $node->book['original_bid']))) {
      // The node can become a new book, if it is not one already.
      $options = array($nid => $this->t('- Create a new book -')) + $options;
    }
217
    if (!$node->book['bid']) {
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242
      // The node is not currently in the hierarchy.
      $options = array(0 => $this->t('- None -')) + $options;
    }

    // Add a drop-down to select the destination book.
    $form['book']['bid'] = array(
      '#type' => 'select',
      '#title' => $this->t('Book'),
      '#default_value' => $node->book['bid'],
      '#options' => $options,
      '#access' => (bool) $options,
      '#description' => $this->t('Your page will be a part of the selected book.'),
      '#weight' => -5,
      '#attributes' => array('class' => array('book-title-select')),
      '#ajax' => array(
        'callback' => 'book_form_update',
        'wrapper' => 'edit-book-plid-wrapper',
        'effect' => 'fade',
        'speed' => 'fast',
      ),
    );
    return $form;
  }

  /**
243
   * {@inheritdoc}
244 245 246 247 248 249
   */
  public function checkNodeIsRemovable(NodeInterface $node) {
    return (!empty($node->book['bid']) && (($node->book['bid'] != $node->id()) || !$node->book['has_children']));
  }

  /**
250
   * {@inheritdoc}
251 252 253 254 255
   */
  public function updateOutline(NodeInterface $node) {
    if (empty($node->book['bid'])) {
      return FALSE;
    }
256

257 258 259 260 261 262 263 264
    if (!empty($node->book['bid'])) {
      if ($node->book['bid'] == 'new') {
        // New nodes that are their own book.
        $node->book['bid'] = $node->id();
      }
      elseif (!isset($node->book['original_bid'])) {
        $node->book['original_bid'] = $node->book['bid'];
      }
265 266
    }

267 268 269
    // Ensure we create a new book link if either the node itself is new, or the
    // bid was selected the first time, so that the original_bid is still empty.
    $new = empty($node->book['nid']) || empty($node->book['original_bid']);
270

271
    $node->book['nid'] = $node->id();
272

273
    // Create a new book from a node.
274
    if ($node->book['bid'] == $node->id()) {
275
      $node->book['pid'] = 0;
276
    }
277 278 279 280 281
    elseif ($node->book['pid'] < 0) {
      // -1 is the default value in BookManager::addParentSelectFormElements().
      // The node save should have set the bid equal to the node ID, but
      // handle it here if it did not.
      $node->book['pid'] = $node->book['bid'];
282
    }
283 284
    return $this->saveBookLink($node->book, $new);
  }
285

286 287 288 289 290 291 292 293 294 295
  /**
   * {@inheritdoc}
   */
  public function getBookParents(array $item, array $parent = array()) {
    $book = array();
    if ($item['pid'] == 0) {
      $book['p1'] = $item['nid'];
      for ($i = 2; $i <= static::BOOK_MAX_DEPTH; $i++) {
        $parent_property = "p$i";
        $book[$parent_property] = 0;
296
      }
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
      $book['depth'] = 1;
    }
    else {
      $i = 1;
      $book['depth'] = $parent['depth'] + 1;
      while ($i < $book['depth']) {
        $p = 'p' . $i++;
        $book[$p] = $parent[$p];
      }
      $p = 'p' . $i++;
      // The parent (p1 - p9) corresponding to the depth always equals the nid.
      $book[$p] = $item['nid'];
      while ($i <= static::BOOK_MAX_DEPTH) {
        $p = 'p' . $i++;
        $book[$p] = 0;
312 313
      }
    }
314
    return $book;
315 316 317 318 319
  }

  /**
   * Builds the parent selection form element for the node form or outline tab.
   *
320 321
   * This function is also called when generating a new set of options during
   * the Ajax callback, so an array is returned that can be used to replace an
322 323 324
   * existing form element.
   *
   * @param array $book_link
325
   *   A fully loaded book link that is part of the book hierarchy.
326 327 328 329 330
   *
   * @return array
   *   A parent selection form element.
   */
  protected function addParentSelectFormElements(array $book_link) {
331 332
    $config = $this->configFactory->get('book.settings');
    if ($config->get('override_parent_selector')) {
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
      return array();
    }
    // Offer a message or a drop-down to choose a different parent page.
    $form = array(
      '#type' => 'hidden',
      '#value' => -1,
      '#prefix' => '<div id="edit-book-plid-wrapper">',
      '#suffix' => '</div>',
    );

    if ($book_link['nid'] === $book_link['bid']) {
      // This is a book - at the top level.
      if ($book_link['original_bid'] === $book_link['bid']) {
        $form['#prefix'] .= '<em>' . $this->t('This is the top-level page in this book.') . '</em>';
      }
      else {
        $form['#prefix'] .= '<em>' . $this->t('This will be the top-level page in this book.') . '</em>';
      }
    }
    elseif (!$book_link['bid']) {
      $form['#prefix'] .= '<em>' . $this->t('No book selected.') . '</em>';
    }
    else {
      $form = array(
        '#type' => 'select',
        '#title' => $this->t('Parent item'),
359 360 361
        '#default_value' => $book_link['pid'],
        '#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.', array('!maxdepth' => static::BOOK_MAX_DEPTH)),
        '#options' => $this->getTableOfContents($book_link['bid'], $book_link['parent_depth_limit'], array($book_link['nid'])),
362 363 364 365 366
        '#attributes' => array('class' => array('book-title-select')),
        '#prefix' => '<div id="edit-book-plid-wrapper">',
        '#suffix' => '</div>',
      );
    }
367
    $this->renderer->addCacheableDependency($form, $config);
368 369 370 371 372

    return $form;
  }

  /**
373
   * Recursively processes and formats book links for getTableOfContents().
374 375
   *
   * This helper function recursively modifies the table of contents array for
376 377 378
   * each item in the book tree, ignoring items in the exclude array or at a
   * depth greater than the limit. Truncates titles over thirty characters and
   * appends an indentation string incremented by depth.
379 380
   *
   * @param array $tree
381
   *   The data structure of the book's outline tree. Includes hidden links.
382
   * @param string $indent
383
   *   A string appended to each node title. Increments by '--' per depth
384 385
   *   level.
   * @param array $toc
386 387
   *   Reference to the table of contents array. This is modified in place, so
   *   the function does not have a return value.
388
   * @param array $exclude
389 390
   *   Optional array of Node ID values. Any link whose node ID is in this
   *   array will be excluded (along with its children).
391
   * @param int $depth_limit
392 393
   *   Any link deeper than this value will be excluded (along with its
   *   children).
394 395
   */
  protected function recurseTableOfContents(array $tree, $indent, array &$toc, array $exclude, $depth_limit) {
396
    $nids = array();
397 398 399 400 401
    foreach ($tree as $data) {
      if ($data['link']['depth'] > $depth_limit) {
        // Don't iterate through any links on this level.
        break;
      }
402 403 404 405
      if (!in_array($data['link']['nid'], $exclude)) {
        $nids[] = $data['link']['nid'];
      }
    }
406

407
    $nodes = $this->entityManager->getStorage('node')->loadMultiple($nids);
408 409 410 411 412 413 414 415 416

    foreach ($tree as $data) {
      $nid = $data['link']['nid'];
      if (in_array($nid, $exclude)) {
        continue;
      }
      $toc[$nid] = $indent . ' ' . Unicode::truncate($nodes[$nid]->label(), 30, TRUE, TRUE);
      if ($data['below']) {
        $this->recurseTableOfContents($data['below'], $indent . '--', $toc, $exclude, $depth_limit);
417 418 419 420 421
      }
    }
  }

  /**
422
   * {@inheritdoc}
423 424
   */
  public function getTableOfContents($bid, $depth_limit, array $exclude = array()) {
425
    $tree = $this->bookTreeAllData($bid);
426 427 428 429 430 431
    $toc = array();
    $this->recurseTableOfContents($tree, '', $toc, $exclude, $depth_limit);

    return $toc;
  }

432
  /**
433
   * {@inheritdoc}
434
   */
435 436
  public function deleteFromBook($nid) {
    $original = $this->loadBookLink($nid, FALSE);
437 438
    $this->bookOutlineStorage->delete($nid);

439 440
    if ($nid == $original['bid']) {
      // Handle deletion of a top-level post.
441 442
      $result = $this->bookOutlineStorage->loadBookChildren($nid);

443 444 445 446 447 448 449
      foreach ($result as $child) {
        $child['bid'] = $child['nid'];
        $this->updateOutline($child);
      }
    }
    $this->updateOriginalParent($original);
    $this->books = NULL;
450
    Cache::invalidateTags(array('bid:' . $original['bid']));
451 452
  }

453
  /**
454
   * {@inheritdoc}
455
   */
456 457
  public function bookTreeAllData($bid, $link = NULL, $max_depth = NULL) {
    $tree = &drupal_static(__METHOD__, array());
458
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
459

460 461 462 463 464
    // Use $nid as a flag for whether the data being loaded is for the whole
    // tree.
    $nid = isset($link['nid']) ? $link['nid'] : 0;
    // Generate a cache ID (cid) specific for this $bid, $link, $language, and
    // depth.
465
    $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->getId() . ':' . (int) $max_depth;
466 467

    if (!isset($tree[$cid])) {
468 469 470 471 472 473
      // If the tree data was not in the static cache, build $tree_parameters.
      $tree_parameters = array(
        'min_depth' => 1,
        'max_depth' => $max_depth,
      );
      if ($nid) {
474 475 476
        $active_trail = $this->getActiveTrailIds($bid, $link);
        $tree_parameters['expanded'] = $active_trail;
        $tree_parameters['active_trail'] = $active_trail;
477
        $tree_parameters['active_trail'][] = $nid;
478 479
      }

480
      // Build the tree using the parameters; the resulting tree will be cached.
481
      $tree[$cid] = $this->bookTreeBuild($bid, $tree_parameters);
482 483 484 485 486
    }

    return $tree[$cid];
  }

487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502
  /**
   * {@inheritdoc}
   */
  public function getActiveTrailIds($bid, $link) {
    $nid = isset($link['nid']) ? $link['nid'] : 0;
    // The tree is for a single item, so we need to match the values in its
    // p columns and 0 (the top level) with the plid values of other links.
    $active_trail = array(0);
    for ($i = 1; $i < static::BOOK_MAX_DEPTH; $i++) {
      if (!empty($link["p$i"])) {
        $active_trail[] = $link["p$i"];
      }
    }
    return $active_trail;
  }

503
  /**
504
   * {@inheritdoc}
505 506
   */
  public function bookTreeOutput(array $tree) {
507
    $items = $this->buildItems($tree);
508

509 510 511 512 513 514 515 516 517 518 519 520 521
    $build = [];

    if ($items) {
      // Make sure drupal_render() does not re-order the links.
      $build['#sorted'] = TRUE;
      // Get the book id from the last link.
      $item = end($items);
      // Add the theme wrapper for outer markup.
      // Allow menu-specific theme overrides.
      $build['#theme'] = 'book_tree__book_toc_' . $item['original_link']['bid'];
      $build['#items'] = $items;
      // Set cache tag.
      $build['#cache']['tags'][] = 'config:system.book.' . $item['original_link']['bid'];
522 523
    }

524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541
    return $build;
  }

  /**
   * Builds the #items property for a book tree's renderable array.
   *
   * Helper function for ::bookTreeOutput().
   *
   * @param array $tree
   *   A data structure representing the tree.
   *
   * @return array
   *   The value to use for the #items property of a renderable menu.
   */
  protected function buildItems(array $tree) {
    $items = [];

    foreach ($tree as $data) {
542
      $class = ['menu-item'];
543 544 545 546
      // Generally we only deal with visible links, but just in case.
      if (!$data['link']['access']) {
        continue;
      }
547 548
      // Set a class for the <li>-tag. Since $data['below'] may contain local
      // tasks, only set 'expanded' class if the link also has children within
549
      // the current book.
550
      if ($data['link']['has_children'] && $data['below']) {
551
        $class[] = 'menu-item--expanded';
552 553
      }
      elseif ($data['link']['has_children']) {
554
        $class[] = 'menu-item--collapsed';
555
      }
556

557 558
      // Set a class if the link is in the active trail.
      if ($data['link']['in_active_trail']) {
559
        $class[] = 'menu-item--active-trail';
560 561
      }

562
      // Allow book-specific theme overrides.
563 564 565 566
      $element = [];
      $element['attributes'] = new Attribute();
      $element['attributes']['class'] = $class;
      $element['title'] = $data['link']['title'];
567
      $node = $this->entityManager->getStorage('node')->load($data['link']['nid']);
568 569 570 571 572
      $element['url'] = $node->urlInfo();
      $element['localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : [];
      $element['localized_options']['set_active_class'] = TRUE;
      $element['below'] = $data['below'] ? $this->buildItems($data['below']) : [];
      $element['original_link'] = $data['link'];
573
      // Index using the link's unique nid.
574
      $items[$data['link']['nid']] = $element;
575 576
    }

577
    return $items;
578 579 580
  }

  /**
581
   * Builds a book tree, translates links, and checks access.
582
   *
583 584
   * @param int $bid
   *   The Book ID to find links for.
585 586
   * @param array $parameters
   *   (optional) An associative array of build parameters. Possible keys:
587 588 589
   *   - expanded: An array of parent link ids to return only book links that
   *     are children of one of the plids in this list. If empty, the whole
   *     outline is built, unless 'only_active_trail' is TRUE.
590 591
   *   - active_trail: An array of nids, representing the coordinates of the
   *     currently active book link.
592 593
   *   - only_active_trail: Whether to only return links that are in the active
   *     trail. This option is ignored, if 'expanded' is non-empty.
594 595 596
   *   - min_depth: The minimum depth of book links in the resulting tree.
   *     Defaults to 1, which is the default to build a whole tree for a book.
   *   - max_depth: The maximum depth of book links in the resulting tree.
597 598 599 600
   *   - conditions: An associative array of custom database select query
   *     condition key/value pairs; see _menu_build_tree() for the actual query.
   *
   * @return array
601
   *   A fully built book tree.
602
   */
603 604 605
  protected function bookTreeBuild($bid, array $parameters = array()) {
    // Build the book tree.
    $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 609 610 611
    return $data['tree'];
  }

  /**
612
   * Builds a book tree.
613 614 615
   *
   * This function may be used build the data for a menu tree only, for example
   * to further massage the data manually before further processing happens.
616
   * _menu_tree_check_access() needs to be invoked afterwards.
617 618 619
   *
   * @see menu_build_tree()
   */
620
  protected function doBookTreeBuild($bid, array $parameters = array()) {
621
    // Static cache of already built menu trees.
622
    $trees = &drupal_static(__METHOD__, array());
623
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
624 625 626 627 628 629

    // Build the cache id; sort parents to prevent duplicate storage and remove
    // default parameter values.
    if (isset($parameters['expanded'])) {
      sort($parameters['expanded']);
    }
630
    $tree_cid = 'book-links:' . $bid . ':tree-data:' . $language_interface->getId() . ':' . hash('sha256', serialize($parameters));
631

632
    // If we do not have this tree in the static cache, check {cache_data}.
633
    if (!isset($trees[$tree_cid])) {
634
      $cache = \Drupal::cache('data')->get($tree_cid);
635
      if ($cache && $cache->data) {
636 637 638 639 640 641
        $trees[$tree_cid] = $cache->data;
      }
    }

    if (!isset($trees[$tree_cid])) {
      $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
642
      $result = $this->bookOutlineStorage->getBookMenuTree($bid, $parameters, $min_depth, static::BOOK_MAX_DEPTH);
643 644 645

      // Build an ordered array of links using the query result object.
      $links = array();
646 647 648
      foreach ($result as $link) {
        $link = (array) $link;
        $links[$link['nid']] = $link;
649 650
      }
      $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
651
      $data['tree'] = $this->buildBookOutlineData($links, $active_trail, $min_depth);
652 653 654 655
      $data['node_links'] = array();
      $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);

      // Cache the data, if it is not already in the cache.
656
      \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, array('bid:' . $bid));
657 658 659 660 661 662 663
      $trees[$tree_cid] = $data;
    }

    return $trees[$tree_cid];
  }

  /**
664
   * {@inheritdoc}
665 666 667 668 669
   */
  public function bookTreeCollectNodeLinks(&$tree, &$node_links) {
    // All book links are nodes.
    // @todo clean this up.
    foreach ($tree as $key => $v) {
670 671 672
      $nid = $v['link']['nid'];
      $node_links[$nid][$tree[$key]['link']['nid']] = &$tree[$key]['link'];
      $tree[$key]['link']['access'] = FALSE;
673 674 675 676 677 678
      if ($tree[$key]['below']) {
        $this->bookTreeCollectNodeLinks($tree[$key]['below'], $node_links);
      }
    }
  }

679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711
  /**
   * {@inheritdoc}
   */
  public function bookTreeGetFlat(array $book_link) {
    if (!isset($this->bookTreeFlattened[$book_link['nid']])) {
      // Call $this->bookTreeAllData() to take advantage of caching.
      $tree = $this->bookTreeAllData($book_link['bid'], $book_link, $book_link['depth'] + 1);
      $this->bookTreeFlattened[$book_link['nid']] = array();
      $this->flatBookTree($tree, $this->bookTreeFlattened[$book_link['nid']]);
    }

    return $this->bookTreeFlattened[$book_link['nid']];
  }

  /**
   * Recursively converts a tree of menu links to a flat array.
   *
   * @param array $tree
   *   A tree of menu links in an array.
   * @param array $flat
   *   A flat array of the menu links from $tree, passed by reference.
   *
   * @see static::bookTreeGetFlat().
   */
  protected function flatBookTree(array $tree, array &$flat) {
    foreach ($tree as $data) {
      $flat[$data['link']['nid']] = $data['link'];
      if ($data['below']) {
        $this->flatBookTree($data['below'], $flat);
      }
    }
  }

712
  /**
713 714 715
   * {@inheritdoc}
   */
  public function loadBookLink($nid, $translate = TRUE) {
716 717 718 719 720 721 722 723
    $links = $this->loadBookLinks(array($nid), $translate);
    return isset($links[$nid]) ? $links[$nid] : FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function loadBookLinks($nids, $translate = TRUE) {
724
    $result = $this->bookOutlineStorage->loadMultiple($nids, $translate);
725 726 727 728 729 730
    $links = array();
    foreach ($result as $link) {
      if ($translate) {
        $this->bookLinkTranslate($link);
      }
      $links[$link['nid']] = $link;
731
    }
732 733

    return $links;
734 735 736 737 738 739 740 741 742 743 744
  }

  /**
   * {@inheritdoc}
   */
  public function saveBookLink(array $link, $new) {
    // Keep track of Book IDs for cache clear.
    $affected_bids[$link['bid']] = $link['bid'];
    $link += $this->getLinkDefaults($link['nid']);
    if ($new) {
      // Insert new.
745 746 747
      $parents = $this->getBookParents($link, (array) $this->loadBookLink($link['pid'], FALSE));
      $this->bookOutlineStorage->insert($link, $parents);

748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781
      // Update the has_children status of the parent.
      $this->updateParent($link);
    }
    else {
      $original = $this->loadBookLink($link['nid'], FALSE);
      // Using the Book ID as the key keeps this unique.
      $affected_bids[$original['bid']] = $original['bid'];
      // Handle links that are moving.
      if ($link['bid'] != $original['bid'] || $link['pid'] != $original['pid']) {
        // Update the bid for this page and all children.
        if ($link['pid'] == 0) {
          $link['depth'] = 1;
          $parent = array();
        }
        // In case the form did not specify a proper PID we use the BID as new
        // parent.
        elseif (($parent_link = $this->loadBookLink($link['pid'], FALSE)) && $parent_link['bid'] != $link['bid']) {
          $link['pid'] = $link['bid'];
          $parent = $this->loadBookLink($link['pid'], FALSE);
          $link['depth'] = $parent['depth'] + 1;
        }
        else {
          $parent = $this->loadBookLink($link['pid'], FALSE);
          $link['depth'] = $parent['depth'] + 1;
        }
        $this->setParents($link, $parent);
        $this->moveChildren($link, $original);

        // Update the has_children status of the original parent.
        $this->updateOriginalParent($original);
        // Update the has_children status of the new parent.
        $this->updateParent($link);
      }
      // Update the weight and pid.
782 783 784 785 786
      $this->bookOutlineStorage->update($link['nid'], array(
        'weight' => $link['weight'],
        'pid' => $link['pid'],
        'bid' => $link['bid'],
      ));
787
    }
788
    $cache_tags = [];
789
    foreach ($affected_bids as $bid) {
790
      $cache_tags[] = 'bid:' . $bid;
791
    }
792
    Cache::invalidateTags($cache_tags);
793
    return $link;
794 795 796 797
  }

  /**
   * Moves children from the original parent to the updated link.
798
   *
799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826
   * @param array $link
   *   The link being saved.
   * @param array $original
   *   The original parent of $link.
   */
  protected function moveChildren(array $link, array $original) {
    $p = 'p1';
    $expressions = array();
    for ($i = 1; $i <= $link['depth']; $p = 'p' . ++$i) {
      $expressions[] = array($p, ":p_$i", array(":p_$i" => $link[$p]));
    }
    $j = $original['depth'] + 1;
    while ($i <= static::BOOK_MAX_DEPTH && $j <= static::BOOK_MAX_DEPTH) {
      $expressions[] = array('p' . $i++, 'p' . $j++, array());
    }
    while ($i <= static::BOOK_MAX_DEPTH) {
      $expressions[] = array('p' . $i++, 0, array());
    }

    $shift = $link['depth'] - $original['depth'];
    if ($shift > 0) {
      // The order of expressions must be reversed so the new values don't
      // overwrite the old ones before they can be used because "Single-table
      // UPDATE assignments are generally evaluated from left to right"
      // @see http://dev.mysql.com/doc/refman/5.0/en/update.html
      $expressions = array_reverse($expressions);
    }

827
    $this->bookOutlineStorage->updateMovedChildren($link['bid'], $original, $expressions, $shift);
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848
  }

  /**
   * Sets the has_children flag of the parent of the node.
   *
   * This method is mostly called when a book link is moved/created etc. So we
   * want to update the has_children flag of the new parent book link.
   *
   * @param array $link
   *   The book link, data reflecting its new position, whose new parent we want
   *   to update.
   *
   * @return bool
   *   TRUE if the update was successful (either there is no parent to update,
   *   or the parent was updated successfully), FALSE on failure.
   */
  protected function updateParent(array $link) {
    if ($link['pid'] == 0) {
      // Nothing to update.
      return TRUE;
    }
849
    return $this->bookOutlineStorage->update($link['pid'], array('has_children' => 1));
850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871
  }

  /**
   * Updates the has_children flag of the parent of the original node.
   *
   * This method is called when a book link is moved or deleted. So we want to
   * update the has_children flag of the parent node.
   *
   * @param array $original
   *   The original link whose parent we want to update.
   *
   * @return bool
   *   TRUE if the update was successful (either there was no original parent to
   *   update, or the original parent was updated successfully), FALSE on
   *   failure.
   */
  protected function updateOriginalParent(array $original) {
    if ($original['pid'] == 0) {
      // There were no parents of this link. Nothing to update.
      return TRUE;
    }
    // Check if $original had at least one child.
872
    $original_number_of_children = $this->bookOutlineStorage->countOriginalLinkChildren($original);
873 874 875 876 877

    $parent_has_children = ((bool) $original_number_of_children) ? 1 : 0;
    // Update the parent. If the original link did not have children, then the
    // parent now does not have children. If the original had children, then the
    // the parent has children now (still).
878
    return $this->bookOutlineStorage->update($original['pid'], array('has_children' => $parent_has_children));
879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905
  }

  /**
   * Sets the p1 through p9 properties for a book link being saved.
   *
   * @param array $link
   *   The book link to update.
   * @param array $parent
   *   The parent values to set.
   */
  protected function setParents(array &$link, array $parent) {
    $i = 1;
    while ($i < $link['depth']) {
      $p = 'p' . $i++;
      $link[$p] = $parent[$p];
    }
    $p = 'p' . $i++;
    // The parent (p1 - p9) corresponding to the depth always equals the nid.
    $link[$p] = $link['nid'];
    while ($i <= static::BOOK_MAX_DEPTH) {
      $p = 'p' . $i++;
      $link[$p] = 0;
    }
  }

  /**
   * {@inheritdoc}
906 907 908
   */
  public function bookTreeCheckAccess(&$tree, $node_links = array()) {
    if ($node_links) {
909
      // @todo Extract that into its own method.
910
      $nids = array_keys($node_links);
911

912 913
      // @todo This should be actually filtering on the desired node status
      //   field language and just fall back to the default language.
914
      $nids = \Drupal::entityQuery('node')
915
        ->condition('nid', $nids, 'IN')
916 917
        ->condition('status', 1)
        ->execute();
918 919 920 921 922 923 924

      foreach ($nids as $nid) {
        foreach ($node_links[$nid] as $mlid => $link) {
          $node_links[$nid][$mlid]['access'] = TRUE;
        }
      }
    }
925
    $this->doBookTreeCheckAccess($tree);
926 927 928 929 930
  }

  /**
   * Sorts the menu tree and recursively checks access for each item.
   */
931
  protected function doBookTreeCheckAccess(&$tree) {
932 933 934
    $new_tree = array();
    foreach ($tree as $key => $v) {
      $item = &$tree[$key]['link'];
935
      $this->bookLinkTranslate($item);
936 937
      if ($item['access']) {
        if ($tree[$key]['below']) {
938
          $this->doBookTreeCheckAccess($tree[$key]['below']);
939 940
        }
        // The weights are made a uniform 5 digits by adding 50000 as an offset.
941 942 943
        // After calling $this->bookLinkTranslate(), $item['title'] has the
        // translated title. Adding the nid to the end of the index insures that
        // it is unique.
944
        $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['nid']] = $tree[$key];
945 946 947 948 949 950 951 952
      }
    }
    // Sort siblings in the tree based on the weights and localized titles.
    ksort($new_tree);
    $tree = $new_tree;
  }

  /**
953
   * {@inheritdoc}
954
   */
955 956 957 958
  public function bookLinkTranslate(&$link) {
    $node = NULL;
    // Access will already be set in the tree functions.
    if (!isset($link['access'])) {
959
      $node = $this->entityManager->getStorage('node')->load($link['nid']);
960
      $link['access'] = $node && $node->access('view');
961 962
    }
    // For performance, don't localize a link the user can't access.
963 964 965
    if ($link['access']) {
      // @todo - load the nodes en-mass rather than individually.
      if (!$node) {
966
        $node = $this->entityManager->getStorage('node')
967
          ->load($link['nid']);
968
      }
969 970 971
      // The node label will be the value for the current user's language.
      $link['title'] = $node->label();
      $link['options'] = array();
972
    }
973
    return $link;
974 975 976
  }

  /**
977
   * Sorts and returns the built data representing a book tree.
978 979
   *
   * @param array $links
980
   *   A flat array of book links that are part of the book. Each array element
981 982
   *   is an associative array of information about the book link, containing
   *   the fields from the {book} table. This array must be ordered depth-first.
983
   * @param array $parents
984 985
   *   An array of the node ID values that are in the path from the current
   *   page to the root of the book tree.
986
   * @param int $depth
987
   *   The minimum depth to include in the returned book tree.
988 989
   *
   * @return array
990
   *   An array of book links in the form of a tree. Each item in the tree is an
991
   *   associative array containing:
992
   *   - link: The book link item from $links, with additional element
993
   *     'in_active_trail' (TRUE if the link ID was in $parents).
994 995 996 997
   *   - below: An array containing the sub-tree of this item, where each
   *     element is a tree item array with 'link' and 'below' elements. This
   *     array will be empty if the book link has no items in its sub-tree
   *     having a depth greater than or equal to $depth.
998
   */
999
  protected function buildBookOutlineData(array $links, array $parents = array(), $depth = 1) {
1000 1001
    // Reverse the array so we can use the more efficient array_pop() function.
    $links = array_reverse($links);
1002
    return $this->buildBookOutlineRecursive($links, $parents, $depth);