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

3 4
namespace Drupal\book;

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 11
use Drupal\Core\Session\AccountInterface;
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 25 26

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

28 29 30
  /**
   * Entity manager Service Object.
   *
31
   * @var \Drupal\Core\Entity\EntityManagerInterface
32 33 34
   */
  protected $entityManager;

35 36 37
  /**
   * Config Factory Service Object.
   *
38
   * @var \Drupal\Core\Config\ConfigFactoryInterface
39 40 41
   */
  protected $configFactory;

42 43 44 45 46 47 48
  /**
   * Books Array.
   *
   * @var array
   */
  protected $books;

49 50 51 52 53 54 55
  /**
   * Book outline storage.
   *
   * @var \Drupal\book\BookOutlineStorageInterface
   */
  protected $bookOutlineStorage;

56 57 58 59 60 61 62
  /**
   * Stores flattened book trees.
   *
   * @var array
   */
  protected $bookTreeFlattened;

63 64 65 66 67 68 69
  /**
   * The renderer.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

70 71 72
  /**
   * Constructs a BookManager object.
   */
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 85 86 87 88 89 90 91 92 93 94 95
   */
  public function getAllBooks() {
    if (!isset($this->books)) {
      $this->loadBooks();
    }
    return $this->books;
  }

  /**
   * Loads Books Array.
   */
  protected function loadBooks() {
    $this->books = array();
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 106
        $nid = $link['nid'];
        if (isset($nodes[$nid]) && $nodes[$nid]->status) {
107
          $link['url'] = $nodes[$nid]->urlInfo();
108 109 110 111
          $link['title'] = $nodes[$nid]->label();
          $link['type'] = $nodes[$nid]->bundle();
          $this->books[$link['bid']] = $link;
        }
112 113 114 115
      }
    }
  }

116
  /**
117
   * {@inheritdoc}
118 119 120 121 122 123
   */
  public function getLinkDefaults($nid) {
    return array(
      'original_bid' => 0,
      'nid' => $nid,
      'bid' => 0,
124
      'pid' => 0,
125 126 127 128 129 130 131
      'has_children' => 0,
      'weight' => 0,
      'options' => array(),
    );
  }

  /**
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 140
   * Determine the relative depth of the children of a given book link.
   *
141
   * @param array $book_link
142 143 144 145 146
   *   The book link.
   *
   * @return int
   *   The difference between the max depth in the book tree and the depth of
   *   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 158
    // 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.
159 160
    if ($form_state->hasValue('book')) {
      $node->book = $form_state->getValue('book');
161 162 163 164 165
    }
    $form['book'] = array(
      '#type' => 'details',
      '#title' => $this->t('Book outline'),
      '#weight' => 10,
166
      '#open' => !$collapsed,
167 168 169 170 171
      '#group' => 'advanced',
      '#attributes' => array(
        'class' => array('book-outline-form'),
      ),
      '#attached' => array(
172
        'library' => array('book/drupal.book'),
173 174 175
      ),
      '#tree' => TRUE,
    );
176
    foreach (array('nid', 'has_children', 'original_bid', 'parent_depth_limit') as $key) {
177 178 179 180 181 182
      $form['book'][$key] = array(
        '#type' => 'value',
        '#value' => $node->book[$key],
      );
    }

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

185 186
    // @see \Drupal\book\Form\BookAdminEditForm::bookAdminTableTree(). The
    // weight may be larger than 15.
187 188 189 190 191 192 193 194 195 196 197
    $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)) {
198 199
      // This is the top level node in a maximum depth book and thus cannot be
      // moved.
200 201 202 203 204 205 206 207 208 209 210 211
      $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;
    }
212
    if (!$node->book['bid']) {
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
      // 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;
  }

  /**
238
   * {@inheritdoc}
239 240 241 242 243 244
   */
  public function checkNodeIsRemovable(NodeInterface $node) {
    return (!empty($node->book['bid']) && (($node->book['bid'] != $node->id()) || !$node->book['has_children']));
  }

  /**
245
   * {@inheritdoc}
246 247 248 249 250
   */
  public function updateOutline(NodeInterface $node) {
    if (empty($node->book['bid'])) {
      return FALSE;
    }
251

252 253 254 255 256 257 258 259
    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'];
      }
260 261
    }

262 263 264
    // 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']);
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 273 274 275 276
    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'];
277
    }
278 279
    return $this->saveBookLink($node->book, $new);
  }
280

281 282 283 284 285 286 287 288 289 290
  /**
   * {@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;
291
      }
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
      $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;
307 308
      }
    }
309
    return $book;
310 311 312 313 314
  }

  /**
   * Builds the parent selection form element for the node form or outline tab.
   *
315 316
   * 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
317 318 319
   * existing form element.
   *
   * @param array $book_link
320
   *   A fully loaded book link that is part of the book hierarchy.
321 322 323 324 325
   *
   * @return array
   *   A parent selection form element.
   */
  protected function addParentSelectFormElements(array $book_link) {
326 327
    $config = $this->configFactory->get('book.settings');
    if ($config->get('override_parent_selector')) {
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
      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'),
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.', array('@maxdepth' => static::BOOK_MAX_DEPTH)),
356
        '#options' => $this->getTableOfContents($book_link['bid'], $book_link['parent_depth_limit'], array($book_link['nid'])),
357 358 359 360 361
        '#attributes' => array('class' => array('book-title-select')),
        '#prefix' => '<div id="edit-book-plid-wrapper">',
        '#suffix' => '</div>',
      );
    }
362
    $this->renderer->addCacheableDependency($form, $config);
363 364 365 366 367

    return $form;
  }

  /**
368
   * Recursively processes and formats book links for getTableOfContents().
369 370
   *
   * This helper function recursively modifies the table of contents array for
371 372 373
   * 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.
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 380
   *   level.
   * @param array $toc
381 382
   *   Reference to the table of contents array. This is modified in place, so
   *   the function does not have a return value.
383
   * @param array $exclude
384 385
   *   Optional array of Node ID values. Any link whose node ID is in this
   *   array will be excluded (along with its children).
386
   * @param int $depth_limit
387 388
   *   Any link deeper than this value will be excluded (along with its
   *   children).
389 390
   */
  protected function recurseTableOfContents(array $tree, $indent, array &$toc, array $exclude, $depth_limit) {
391
    $nids = array();
392 393 394
    foreach ($tree as $data) {
      if ($data['link']['depth'] > $depth_limit) {
        // Don't iterate through any links on this level.
395
        return;
396
      }
397 398 399 400
      if (!in_array($data['link']['nid'], $exclude)) {
        $nids[] = $data['link']['nid'];
      }
    }
401

402
    $nodes = $this->entityManager->getStorage('node')->loadMultiple($nids);
403 404 405

    foreach ($tree as $data) {
      $nid = $data['link']['nid'];
406 407
      // Check for excluded or missing node.
      if (empty($nodes[$nid])) {
408 409 410 411 412
        continue;
      }
      $toc[$nid] = $indent . ' ' . Unicode::truncate($nodes[$nid]->label(), 30, TRUE, TRUE);
      if ($data['below']) {
        $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 = array()) {
421
    $tree = $this->bookTreeAllData($bid);
422 423 424 425 426 427
    $toc = array();
    $this->recurseTableOfContents($tree, '', $toc, $exclude, $depth_limit);

    return $toc;
  }

428
  /**
429
   * {@inheritdoc}
430
   */
431 432
  public function deleteFromBook($nid) {
    $original = $this->loadBookLink($nid, FALSE);
433 434
    $this->bookOutlineStorage->delete($nid);

435 436
    if ($nid == $original['bid']) {
      // Handle deletion of a top-level post.
437
      $result = $this->bookOutlineStorage->loadBookChildren($nid);
438 439 440
      $children = $this->entityManager->getStorage('node')->loadMultiple(array_keys($result));
      foreach ($children as $child) {
        $child->book['bid'] = $child->id();
441 442 443 444 445
        $this->updateOutline($child);
      }
    }
    $this->updateOriginalParent($original);
    $this->books = NULL;
446
    Cache::invalidateTags(array('bid:' . $original['bid']));
447 448
  }

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

456 457 458 459 460
    // 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.
461
    $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->getId() . ':' . (int) $max_depth;
462 463

    if (!isset($tree[$cid])) {
464 465 466 467 468 469
      // 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) {
470 471 472
        $active_trail = $this->getActiveTrailIds($bid, $link);
        $tree_parameters['expanded'] = $active_trail;
        $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 481 482
    }

    return $tree[$cid];
  }

483 484 485 486 487 488 489 490 491 492 493 494 495 496 497
  /**
   * {@inheritdoc}
   */
  public function getActiveTrailIds($bid, $link) {
    // 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;
  }

498
  /**
499
   * {@inheritdoc}
500 501
   */
  public function bookTreeOutput(array $tree) {
502
    $items = $this->buildItems($tree);
503

504 505 506 507 508 509 510 511 512 513 514 515 516
    $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'];
517 518
    }

519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536
    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) {
537 538
      $element = [];

539 540 541 542
      // Generally we only deal with visible links, but just in case.
      if (!$data['link']['access']) {
        continue;
      }
543 544
      // Set a class for the <li> tag. Since $data['below'] may contain local
      // tasks, only set 'expanded' to true if the link also has children within
545
      // the current book.
546 547
      $element['is_expanded'] = FALSE;
      $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 556 557
      // Set a helper variable to indicate whether the link is in the active
      // trail.
      $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 564
      $element['attributes'] = new Attribute();
      $element['title'] = $data['link']['title'];
565
      $node = $this->entityManager->getStorage('node')->load($data['link']['nid']);
566 567 568 569 570
      $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'];
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 582
   * @param int $bid
   *   The Book ID to find links for.
583 584
   * @param array $parameters
   *   (optional) An associative array of build parameters. Possible keys:
585 586 587 588 589
   *   - expanded: An array of parent link IDs to return only book links that
   *     are children of one of the parent link IDs in this list. If empty,
   *     the whole outline is built, unless 'only_active_trail' is TRUE.
   *   - active_trail: An array of node IDs, representing the currently active
   *     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 597 598
   *     condition key/value pairs; see
   *     \Drupal\book\BookOutlineStorage::getBookMenuTree() for the actual
   *     query.
599 600
   *
   * @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 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640
   * @param int $bid
   *   The book ID to find links for.
   * @param array $parameters
   *   (optional) An associative array of build parameters. Possible keys:
   *   - expanded: An array of parent link IDs to return only book links that
   *     are children of one of the parent link IDs in this list. If empty,
   *     the whole outline is built, unless 'only_active_trail' is TRUE.
   *   - active_trail: An array of node IDs, representing the currently active
   *     book link.
   *   - only_active_trail: Whether to only return links that are in the active
   *     trail. This option is ignored if 'expanded' is non-empty.
   *   - min_depth: The minimum depth of book links in the resulting tree.
   *     Defaults to 1, which is to build the whole tree for the book.
   *   - max_depth: The maximum depth of book links in the resulting tree.
   *   - conditions: An associative array of custom database select query
   *     condition key/value pairs; see
   *     \Drupal\book\BookOutlineStorage::getBookMenuTree() for the actual
   *     query.
   *
   * @return array
   *   An array with links representing the tree structure of the book.
   *
   * @see \Drupal\book\BookOutlineStorageInterface::getBookMenuTree()
641
   */
642
  protected function doBookTreeBuild($bid, array $parameters = array()) {
643
    // Static cache of already built menu trees.
644
    $trees = &drupal_static(__METHOD__, array());
645
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
646 647 648 649 650 651

    // Build the cache id; sort parents to prevent duplicate storage and remove
    // default parameter values.
    if (isset($parameters['expanded'])) {
      sort($parameters['expanded']);
    }
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 659 660 661 662 663
        $trees[$tree_cid] = $cache->data;
      }
    }

    if (!isset($trees[$tree_cid])) {
      $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 667

      // Build an ordered array of links using the query result object.
      $links = array();
668 669 670
      foreach ($result as $link) {
        $link = (array) $link;
        $links[$link['nid']] = $link;
671 672
      }
      $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
673
      $data['tree'] = $this->buildBookOutlineData($links, $active_trail, $min_depth);
674 675 676 677
      $data['node_links'] = array();
      $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);

      // Cache the data, if it is not already in the cache.
678
      \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, array('bid:' . $bid));
679 680 681 682 683 684 685
      $trees[$tree_cid] = $data;
    }

    return $trees[$tree_cid];
  }

  /**
686
   * {@inheritdoc}
687 688 689 690 691
   */
  public function bookTreeCollectNodeLinks(&$tree, &$node_links) {
    // All book links are nodes.
    // @todo clean this up.
    foreach ($tree as $key => $v) {
692 693 694
      $nid = $v['link']['nid'];
      $node_links[$nid][$tree[$key]['link']['nid']] = &$tree[$key]['link'];
      $tree[$key]['link']['access'] = FALSE;
695 696 697 698 699 700
      if ($tree[$key]['below']) {
        $this->bookTreeCollectNodeLinks($tree[$key]['below'], $node_links);
      }
    }
  }

701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722
  /**
   * {@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.
   *
723
   * @see static::bookTreeGetFlat()
724 725 726 727 728 729 730 731 732 733
   */
  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);
      }
    }
  }

734
  /**
735 736 737
   * {@inheritdoc}
   */
  public function loadBookLink($nid, $translate = TRUE) {
738 739 740 741 742 743 744 745
    $links = $this->loadBookLinks(array($nid), $translate);
    return isset($links[$nid]) ? $links[$nid] : FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function loadBookLinks($nids, $translate = TRUE) {
746
    $result = $this->bookOutlineStorage->loadMultiple($nids, $translate);
747 748 749 750 751 752
    $links = array();
    foreach ($result as $link) {
      if ($translate) {
        $this->bookLinkTranslate($link);
      }
      $links[$link['nid']] = $link;
753
    }
754 755

    return $links;
756 757 758 759 760 761 762 763 764 765 766
  }

  /**
   * {@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.
767 768 769
      $parents = $this->getBookParents($link, (array) $this->loadBookLink($link['pid'], FALSE));
      $this->bookOutlineStorage->insert($link, $parents);

770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803
      // 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.
804 805 806 807 808
      $this->bookOutlineStorage->update($link['nid'], array(
        'weight' => $link['weight'],
        'pid' => $link['pid'],
        'bid' => $link['bid'],
      ));
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 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848
   * @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);
    }

849
    $this->bookOutlineStorage->updateMovedChildren($link['bid'], $original, $expressions, $shift);
850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870
  }

  /**
   * 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;
    }
871
    return $this->bookOutlineStorage->update($link['pid'], array('has_children' => 1));
872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893
  }

  /**
   * 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.
894
    $original_number_of_children = $this->bookOutlineStorage->countOriginalLinkChildren($original);
895 896 897 898 899

    $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).
900
    return $this->bookOutlineStorage->update($original['pid'], array('has_children' => $parent_has_children));
901 902 903 904 905 906
  }

  /**
   * Sets the p1 through p9 properties for a book link being saved.
   *
   * @param array $link
907
   *   The book link to update, passed by reference.
908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927
   * @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}
928 929 930
   */
  public function bookTreeCheckAccess(&$tree, $node_links = array()) {
    if ($node_links) {
931
      // @todo Extract that into its own method.
932
      $nids = array_keys($node_links);
933

934 935
      // @todo This should be actually filtering on the desired node status
      //   field language and just fall back to the default language.
936
      $nids = \Drupal::entityQuery('node')
937
        ->condition('nid', $nids, 'IN')
938 939
        ->condition('status', 1)
        ->execute();
940 941 942 943 944 945 946

      foreach ($nids as $nid) {
        foreach ($node_links[$nid] as $mlid => $link) {
          $node_links[$nid][$mlid]['access'] = TRUE;
        }
      }
    }
947
    $this->doBookTreeCheckAccess($tree);
948 949 950 951
  }

  /**
   * Sorts the menu tree and recursively checks access for each item.
952 953 954
   *
   * @param array $tree
   *   The book tree to operate on.
955
   */
956
  protected function doBookTreeCheckAccess(&$tree) {
957 958 959
    $new_tree = array();
    foreach ($tree as $key => $v) {
      $item = &$tree[$key]['link'];
960
      $this->bookLinkTranslate($item);
961 962
      if ($item['access']) {
        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 967 968
        // After calling $this->bookLinkTranslate(), $item['title'] has the
        // translated title. Adding the nid to the end of the index insures that
        // it is unique.
969
        $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['nid']] = $tree[$key];
970 971 972 973 974 975 976 977
      }
    }
    // Sort siblings in the tree based on the weights and localized titles.
    ksort($new_tree);
    $tree = $new_tree;
  }

  /**
978
   * {@inheritdoc}
979
   */
980 981 982 983
  public function bookLinkTranslate(&$link) {
    $node = NULL;
    // Access will already be set in the tree functions.
    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 989 990
    if ($link['access']) {
      // @todo - load the nodes en-mass rather than individually.
      if (!$node) {
991
        $node = $this->entityManager->getStorage('node')
992
          ->load($link['nid']);
993
      }
994 995 996
      // The node label will be the value for the current user's language.
      $link['title'] = $node->label();
      $link['options'] = array();
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 1007
   *   is an associative array of information about the book link, containing
   *   the fields from the {book} table. This array must be ordered depth-first.
1008
   * @param array $parents
1009 1010
   *   An array of the node ID values that are in the path from the current
   *   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 1020 1021 1022
   *   - 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.
1023
   */
1024
  protected function buildBookOutlineData(array $links, array $parents = array(), $depth = 1) {
1025 1026
    // Reverse the array so we can use the more efficient array_pop() function.
    $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 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047
   *
   * @param array $links
   *   A flat array of book links that are part of the book. Each array element
   *   is an associative array of information about the book link, containing
   *   the fields from the {book} table. This array must be ordered depth-first.
   * @param array $parents
   *   An array of the node ID values that are in the path from the current page
   *   to the root of the book tree.
   * @param int $depth
   *   The minimum depth to include in the returned book tree.
   *
   * @return array
   *   Book tree.
1048
   */
1049
  protected function buildBookOutlineRecursive(&$links, $parents, $depth) {
1050 1051 1052 1053
    $tree = array();
    while ($item = array_pop($links)) {
      // We need to determine if we're on the path to root so we can later build
      // 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']] = array(
1057 1058 1059
        'link' => $item,
        'below' => array(),
      );