BookManager.php 38.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
        '#default_value' => $book_link['pid'],
360
        '#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)),
361
        '#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
    foreach ($tree as $data) {
      if ($data['link']['depth'] > $depth_limit) {
        // Don't iterate through any links on this level.
400
        return;
401
      }
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

    foreach ($tree as $data) {
      $nid = $data['link']['nid'];
411 412
      // Check for excluded or missing node.
      if (empty($nodes[$nid])) {
413 414 415 416 417
        continue;
      }
      $toc[$nid] = $indent . ' ' . Unicode::truncate($nodes[$nid]->label(), 30, TRUE, TRUE);
      if ($data['below']) {
        $this->recurseTableOfContents($data['below'], $indent . '--', $toc, $exclude, $depth_limit);
418 419 420 421 422
      }
    }
  }

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

    return $toc;
  }

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

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

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

461 462 463 464 465
    // 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.
466
    $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->getId() . ':' . (int) $max_depth;
467 468

    if (!isset($tree[$cid])) {
469 470 471 472 473 474
      // 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) {
475 476 477
        $active_trail = $this->getActiveTrailIds($bid, $link);
        $tree_parameters['expanded'] = $active_trail;
        $tree_parameters['active_trail'] = $active_trail;
478
        $tree_parameters['active_trail'][] = $nid;
479 480
      }

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

    return $tree[$cid];
  }

488 489 490 491 492 493 494 495 496 497 498 499 500 501 502
  /**
   * {@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;
  }

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 543
      $element = [];

544 545 546 547
      // Generally we only deal with visible links, but just in case.
      if (!$data['link']['access']) {
        continue;
      }
548 549
      // 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
550
      // the current book.
551 552
      $element['is_expanded'] = FALSE;
      $element['is_collapsed'] = FALSE;
553
      if ($data['link']['has_children'] && $data['below']) {
554
        $element['is_expanded'] = TRUE;
555 556
      }
      elseif ($data['link']['has_children']) {
557
        $element['is_collapsed'] = TRUE;
558
      }
559

560 561 562
      // Set a helper variable to indicate whether the link is in the active
      // trail.
      $element['in_active_trail'] = FALSE;
563
      if ($data['link']['in_active_trail']) {
564
        $element['in_active_trail'] = TRUE;
565 566
      }

567
      // Allow book-specific theme overrides.
568 569
      $element['attributes'] = new Attribute();
      $element['title'] = $data['link']['title'];
570
      $node = $this->entityManager->getStorage('node')->load($data['link']['nid']);
571 572 573 574 575
      $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'];
576
      // Index using the link's unique nid.
577
      $items[$data['link']['nid']] = $element;
578 579
    }

580
    return $items;
581 582 583
  }

  /**
584
   * Builds a book tree, translates links, and checks access.
585
   *
586 587
   * @param int $bid
   *   The Book ID to find links for.
588 589
   * @param array $parameters
   *   (optional) An associative array of build parameters. Possible keys:
590 591 592 593 594
   *   - 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.
595
   *   - only_active_trail: Whether to only return links that are in the active
596
   *     trail. This option is ignored if 'expanded' is non-empty.
597
   *   - min_depth: The minimum depth of book links in the resulting tree.
598
   *     Defaults to 1, which is to build the whole tree for the book.
599
   *   - max_depth: The maximum depth of book links in the resulting tree.
600
   *   - conditions: An associative array of custom database select query
601 602 603
   *     condition key/value pairs; see
   *     \Drupal\book\BookOutlineStorage::getBookMenuTree() for the actual
   *     query.
604 605
   *
   * @return array
606
   *   A fully built book tree.
607
   */
608 609 610
  protected function bookTreeBuild($bid, array $parameters = array()) {
    // Build the book tree.
    $data = $this->doBookTreeBuild($bid, $parameters);
611
    // Check access for the current user to each item in the tree.
612
    $this->bookTreeCheckAccess($data['tree'], $data['node_links']);
613 614 615 616
    return $data['tree'];
  }

  /**
617
   * Builds a book tree.
618 619 620
   *
   * 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.
621
   * _menu_tree_check_access() needs to be invoked afterwards.
622
   *
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645
   * @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()
646
   */
647
  protected function doBookTreeBuild($bid, array $parameters = array()) {
648
    // Static cache of already built menu trees.
649
    $trees = &drupal_static(__METHOD__, array());
650
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
651 652 653 654 655 656

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

659
    // If we do not have this tree in the static cache, check {cache_data}.
660
    if (!isset($trees[$tree_cid])) {
661
      $cache = \Drupal::cache('data')->get($tree_cid);
662
      if ($cache && $cache->data) {
663 664 665 666 667 668
        $trees[$tree_cid] = $cache->data;
      }
    }

    if (!isset($trees[$tree_cid])) {
      $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
669
      $result = $this->bookOutlineStorage->getBookMenuTree($bid, $parameters, $min_depth, static::BOOK_MAX_DEPTH);
670 671 672

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

      // Cache the data, if it is not already in the cache.
683
      \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, array('bid:' . $bid));
684 685 686 687 688 689 690
      $trees[$tree_cid] = $data;
    }

    return $trees[$tree_cid];
  }

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

706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738
  /**
   * {@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);
      }
    }
  }

739
  /**
740 741 742
   * {@inheritdoc}
   */
  public function loadBookLink($nid, $translate = TRUE) {
743 744 745 746 747 748 749 750
    $links = $this->loadBookLinks(array($nid), $translate);
    return isset($links[$nid]) ? $links[$nid] : FALSE;
  }

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

    return $links;
761 762 763 764 765 766 767 768 769 770 771
  }

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

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 804 805 806 807 808
      // 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.
809 810 811 812 813
      $this->bookOutlineStorage->update($link['nid'], array(
        'weight' => $link['weight'],
        'pid' => $link['pid'],
        'bid' => $link['bid'],
      ));
814
    }
815
    $cache_tags = [];
816
    foreach ($affected_bids as $bid) {
817
      $cache_tags[] = 'bid:' . $bid;
818
    }
819
    Cache::invalidateTags($cache_tags);
820
    return $link;
821 822 823 824
  }

  /**
   * Moves children from the original parent to the updated link.
825
   *
826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853
   * @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);
    }

854
    $this->bookOutlineStorage->updateMovedChildren($link['bid'], $original, $expressions, $shift);
855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875
  }

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

  /**
   * 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.
899
    $original_number_of_children = $this->bookOutlineStorage->countOriginalLinkChildren($original);
900 901 902 903 904

    $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).
905
    return $this->bookOutlineStorage->update($original['pid'], array('has_children' => $parent_has_children));
906 907 908 909 910 911
  }

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

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

      foreach ($nids as $nid) {
        foreach ($node_links[$nid] as $mlid => $link) {
          $node_links[$nid][$mlid]['access'] = TRUE;
        }
      }
    }
952
    $this->doBookTreeCheckAccess($tree);
953 954 955 956
  }

  /**
   * Sorts the menu tree and recursively checks access for each item.
957 958 959
   *
   * @param array $tree
   *   The book tree to operate on.
960
   */
961
  protected function doBookTreeCheckAccess(&$tree) {
962 963 964
    $new_tree = array();
    foreach ($tree as $key => $v) {
      $item = &$tree[$key]['link'];
965
      $this->bookLinkTranslate($item);
966 967
      if ($item['access']) {
        if ($tree[$key]['below']) {
968
          $this->doBookTreeCheckAccess($tree[$key]['below']);
969 970
        }
        // The weights are made a uniform 5 digits by adding 50000 as an offset.
971 972 973
        // After calling $this->bookLinkTranslate(), $item['title'] has the
        // translated title. Adding the nid to the end of the index insures that
        // it is unique.
974
        $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['nid']] = $tree[$key];
975 976 977 978 979 980 981 982
      }
    }
    // Sort siblings in the tree based on the weights and localized titles.
    ksort($new_tree);
    $tree = $new_tree;
  }

  /**
983
   * {@inheritdoc}
984
   */
985 986 987 988
  public function bookLinkTranslate(&$link) {
    $node = NULL;
    // Access will already be set in the tree functions.
    if (!isset($link['access'])) {
989
      $node = $this->entityManager->getStorage('node')->load($link['nid']);
990
      $link['access'] = $node && $node->access('view');
991 992
    }
    // For performance, don't localize a link the user can't access.
993 994 995
    if ($link['access']) {
      // @todo - load the nodes en-mass rather than individually.
      if (!$node) {
996
        $node = $this->entityManager->getStorage('node')
997
          ->load($link['nid']);
998
      }
999 1000 1001
      // The node label will be the value for the current user's language.
      $link['title'] = $node->label();
      $link['options'] = array();
1002
    }
1003
    return $link;
1004 1005 1006
  }

  /**
1007
   * Sorts and returns the built data representing a book tree.
1008 1009
   *
   * @param array $links
1010
   *   A flat array of book links that are part of the book. Each array element
1011 1012
   *   is an associative array of information about the book link, containing
   *   the fields from the {book} table. This array must be ordered depth-first.
1013
   * @param array $parents
1014 1015
   *   An array of the node ID values that are in the path from the current
   *   page to the root of the book tree.
1016
   * @param int $depth
1017
   *   The minimum depth to include in the returned book tree.
1018 1019
   *
   * @return array
1020
   *   An array of book links in the form of a tree. Each item in the tree is an
1021
   *   associative array containing:
1022
   *   - link: The book link item from $links, with additional element
1023
   *     'in_active_trail' (TRUE if the link ID was in $parents).
1024 1025 1026 1027
   *   - 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.
1028
   */
1029
  protected function buildBookOutlineData(array $links, array $parents = array(), $depth = 1) {
1030 1031
    // Reverse the array so we can use the more efficient array_pop() function.
    $links = array_reverse($links);
1032
    return $this->buildBookOutlineRecursive($links, $parents, $depth);
1033 1034 1035
  }

  /**
1036
   * Builds the data representing a book tree.
1037 1038
   *
   * The function is a bit complex because the rendering of a link depends on
1039
   * the next book link.
1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053
   *
   * @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.
   *
1054
   */
1055
  protected function buildBookOutlineRecursive(&$links, $parents, $depth) {
1056 1057 1058 1059
    $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.
1060
      $item['in_active_trail'] = in_array($item['nid'], $parents);
1061
      // Add the current link to the tree.
1062
      $tree[$item['nid']] = array(
1063 1064 1065
        'link' => $item,
        'below' => array(),
      );
1066 1067 1068
      // Look ahead to the next link, but leave it on the array so it's
      // available to other recursive function calls if we return or build a
      // sub-tree.
1069 1070 1071
      $next = end($links);
      // Check whether the next link is the first in a new sub-tree.
      if ($next && $next['depth'] > $depth) {
1072 1073
        // Recursively call buildBookOutlineRecursive to build the sub-tree.
        $tree[$item['nid']]['below'] = $this->buildBookOutlineRecursive($links, $parents, $next['depth']);
1074 1075 1076
        // Fetch next link after filling the sub-tree.
        $next = end($links);
      }
1077
      // Determine if we should exit the loop and $request = return.
1078 1079 1080 1081 1082 1083 1084 1085
      if (!$next || $next['depth'] < $depth) {
        break;
      }
    }
    return $tree;
  }

  /**
1086
   * {@inheritdoc}
1087
   */
1088
  public function bookSubtreeData($link) {
1089
    $tree = &drupal_static(__METHOD__, array());
1090

1091 1092
    // Generate a cache ID (cid) specific for this $link.
    $cid = 'book-links:subtree-cid:' . $link['nid'];
1093 1094

    if (!isset($tree[$cid])) {
1095
      $tree_cid_cache = \Drupal::cache('data')->get($cid);
1096

1097
      if ($tree_cid_cache && $tree_cid_cache->data) {
1098 1099
        // If the cache entry exists, it will just be the cid for the actual
        // data. This avoids duplication of large amounts of data.
1100
        $cache = \Drupal::cache('data')->get($tree_cid_cache->data);
1101 1102 1103 1104 1105 1106 1107 1108

        if ($cache && isset($cache->data)) {
          $data = $cache->data;
        }
      }

      // If the subtree data was not in the cache, $data will be NULL.
      if (!isset($data)) {
1109
        $result = $this->bookOutlineStorage->getBookSubtree($link, static::BOOK_MAX_DEPTH);
1110
        $links = array();
1111
        foreach ($result as $item) {
1112 1113
          $links[] = $item;
        }
1114
        $data['tree'] = $this->buildBookOutlineData($links, array(), $link['depth']);
1115 1116 1117
        $data['node_links'] = array();
        $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);
        // Compute the real cid for book subtree data.
1118
        $tree_cid = 'book-links:subtree-data:' . hash('sha256', serialize($data));
1119 1120
        // Cache the data, if it is not already in the cache.

1121
        if (!\Drupal::cache('data')->get($tree_cid)) {
1122
          \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, array('bid:' . $link['bid']));
1123
        }
1124 1125
        // Cache the cid of the (shared) data using the book and item-specific
        // cid.
1126
        \Drupal::cache('data')->set($cid, $tree_cid, Cache::PERMANENT, array('bid:' . $link['bid']));
1127 1128 1129 1130 1131 1132 1133 1134
      }
      // Check access for the current user to each item in the tree.
      $this->bookTreeCheckAccess($data['tree'], $data['node_links']);
      $tree[$cid] = $data['tree'];
    }

    return $tree[$cid];
  }