BookManager.php 36.9 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\Database\Connection;
13
use Drupal\Core\Entity\EntityInterface;
14
use Drupal\Core\Entity\EntityManagerInterface;
15
use Drupal\Core\Language\Language;
16 17
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslationInterface;
18
use Drupal\Core\Config\ConfigFactoryInterface;
19
use Drupal\node\NodeInterface;
20 21

/**
22
 * Defines a book manager.
23
 */
24 25 26 27 28 29
class BookManager implements BookManagerInterface {

  /**
   * Defines the maximum supported depth of the book tree.
   */
  const BOOK_MAX_DEPTH = 9;
30 31 32 33 34 35

  /**
   * Database Service Object.
   *
   * @var \Drupal\Core\Database\Connection
   */
36
  protected $connection;
37

38 39 40
  /**
   * Entity manager Service Object.
   *
41
   * @var \Drupal\Core\Entity\EntityManagerInterface
42 43 44
   */
  protected $entityManager;

45 46 47 48 49 50 51 52 53 54
  /**
   * The translation service.
   *
   * @var \Drupal\Core\StringTranslation\TranslationInterface
   */
  protected $translation;

  /**
   * Config Factory Service Object.
   *
55
   * @var \Drupal\Core\Config\ConfigFactoryInterface
56 57 58
   */
  protected $configFactory;

59 60 61 62 63 64 65 66 67 68
  /**
   * Books Array.
   *
   * @var array
   */
  protected $books;

  /**
   * Constructs a BookManager object.
   */
69
  public function __construct(Connection $connection, EntityManagerInterface $entity_manager, TranslationInterface $translation, ConfigFactoryInterface $config_factory) {
70 71
    $this->connection = $connection;
    $this->entityManager = $entity_manager;
72
    $this->translation = $translation;
73
    $this->configFactory = $config_factory;
74 75 76
  }

  /**
77
   * {@inheritdoc}
78 79 80 81 82 83 84 85 86 87 88 89 90
   */
  public function getAllBooks() {
    if (!isset($this->books)) {
      $this->loadBooks();
    }
    return $this->books;
  }

  /**
   * Loads Books Array.
   */
  protected function loadBooks() {
    $this->books = array();
91
    $nids = $this->connection->query("SELECT DISTINCT(bid) FROM {book}")->fetchCol();
92

93
    if ($nids) {
94
      $query = $this->connection->select('book', 'b', array('fetch' => \PDO::FETCH_ASSOC));
95
      $query->fields('b');
96
      $query->condition('b.nid', $nids);
97
      $query->addTag('node_access');
98
      $query->addMetaData('base_table', 'book');
99
      $book_links = $query->execute();
100

101
      $nodes = $this->entityManager->getStorageController('node')->loadMultiple($nids);
102
      // @todo: Sort by weight and translated title.
103

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

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

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

  /**
   * {@inheritdoc}
   */
  protected function findChildrenRelativeDepth(array $entity) {
143 144 145
    $query = db_select('book');
    $query->addField('book', 'depth');
    $query->condition('bid', $entity['bid']);
146 147 148 149 150
    $query->orderBy('depth', 'DESC');
    $query->range(0, 1);

    $i = 1;
    $p = 'p1';
151
    while ($i <= static::BOOK_MAX_DEPTH && $entity[$p]) {
152 153 154 155 156 157 158
      $query->condition($p, $entity[$p]);
      $p = 'p' . ++$i;
    }

    $max_depth = $query->execute()->fetchField();

    return ($max_depth > $entity['depth']) ? $max_depth - $entity['depth'] : 0;
159 160 161
  }

  /**
162
   * {@inheritdoc}
163
   */
164
  public function addFormElements(array $form, array &$form_state, NodeInterface $node, AccountInterface $account, $collapsed = TRUE) {
165 166 167 168 169 170 171 172 173
    // 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.
    if (isset($form_state['values']['book'])) {
      $node->book = $form_state['values']['book'];
    }
    $form['book'] = array(
      '#type' => 'details',
      '#title' => $this->t('Book outline'),
      '#weight' => 10,
174
      '#open' => !$collapsed,
175 176 177 178 179 180 181 182 183
      '#group' => 'advanced',
      '#attributes' => array(
        'class' => array('book-outline-form'),
      ),
      '#attached' => array(
        'library' => array(array('book', 'drupal.book')),
      ),
      '#tree' => TRUE,
    );
184
    foreach (array('nid', 'has_children', 'original_bid', 'parent_depth_limit') as $key) {
185 186 187 188 189 190
      $form['book'][$key] = array(
        '#type' => 'value',
        '#value' => $node->book[$key],
      );
    }

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

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

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

  /**
252
   * {@inheritdoc}
253 254 255 256 257
   */
  public function updateOutline(NodeInterface $node) {
    if (empty($node->book['bid'])) {
      return FALSE;
    }
258 259 260
    // 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']);
261

262
    $node->book['nid'] = $node->id();
263

264
    // Create a new book from a node.
265
    if ($node->book['bid'] == $node->id()) {
266
      $node->book['pid'] = 0;
267
    }
268 269 270 271 272
    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'];
273
    }
274 275
    return $this->saveBookLink($node->book, $new);
  }
276

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

308
  /**
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330
   * Translates a string to the current language or to a given language.
   *
   * See the t() documentation for details.
   */
  protected function t($string, array $args = array(), array $options = array()) {
    return $this->translation->translate($string, $args, $options);
  }

  /**
   * Builds the parent selection form element for the node form or outline tab.
   *
   * 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
   * existing form element.
   *
   * @param array $book_link
   *   A fully loaded menu link that is part of the book hierarchy.
   *
   * @return array
   *   A parent selection form element.
   */
  protected function addParentSelectFormElements(array $book_link) {
331
    if ($this->configFactory->get('book.settings')->get('override_parent_selector')) {
332 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
      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'),
358 359 360
        '#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'])),
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392
        '#attributes' => array('class' => array('book-title-select')),
        '#prefix' => '<div id="edit-book-plid-wrapper">',
        '#suffix' => '</div>',
      );
    }

    return $form;
  }

  /**
   * Recursively processes and formats menu items for getTableOfContents().
   *
   * This helper function recursively modifies the table of contents array for
   * each item in the menu 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.
   *
   * @param array $tree
   *   The data structure of the book's menu tree. Includes hidden links.
   * @param string $indent
   *   A string appended to each menu item title. Increments by '--' per depth
   *   level.
   * @param array $toc
   *   Reference to the table of contents array. This is modified in place, so the
   *   function does not have a return value.
   * @param array $exclude
   *   Optional array of menu link ID values. Any link whose menu link ID is in
   *   this array will be excluded (along with its children).
   * @param int $depth_limit
   *   Any link deeper than this value will be excluded (along with its children).
   */
  protected function recurseTableOfContents(array $tree, $indent, array &$toc, array $exclude, $depth_limit) {
393
    $nids = array();
394 395 396 397 398
    foreach ($tree as $data) {
      if ($data['link']['depth'] > $depth_limit) {
        // Don't iterate through any links on this level.
        break;
      }
399 400 401 402
      if (!in_array($data['link']['nid'], $exclude)) {
        $nids[] = $data['link']['nid'];
      }
    }
403

404 405 406 407 408 409 410 411 412 413
    $nodes = $this->entityManager->getStorageController('node')->loadMultiple($nids);

    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);
414 415 416 417 418
      }
    }
  }

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

    return $toc;
  }

429
  /**
430
   * {@inheritdoc}
431
   */
432 433
  public function deleteFromBook($nid) {
    $original = $this->loadBookLink($nid, FALSE);
434 435 436
    $this->connection->delete('book')
      ->condition('nid', $nid)
      ->execute();
437 438 439 440 441 442 443 444 445 446 447 448 449
    if ($nid == $original['bid']) {
      // Handle deletion of a top-level post.
      $result = $this->connection->query("SELECT * FROM {book} WHERE pid = :nid", array(
        ':nid' => $nid
      ))->fetchAllAssoc('nid', \PDO::FETCH_ASSOC);
      foreach ($result as $child) {
        $child['bid'] = $child['nid'];
        $this->updateOutline($child);
      }
    }
    $this->updateOriginalParent($original);
    $this->books = NULL;
    \Drupal::cache('menu')->deleteTags(array('bid' => $original['bid']));
450 451
  }

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

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.
    $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->id . ':' . (int) $max_depth;
465 466

    if (!isset($tree[$cid])) {
467 468 469 470 471 472 473 474 475 476 477 478
      // 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) {
        // 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.
        $parents = array(0);
        for ($i = 1; $i < static::BOOK_MAX_DEPTH; $i++) {
          if (!empty($link["p$i"])) {
            $parents[] = $link["p$i"];
479 480
          }
        }
481 482 483
        $tree_parameters['expanded'] = $parents;
        $tree_parameters['active_trail'] = $parents;
        $tree_parameters['active_trail'][] = $nid;
484 485
      }

486 487
      // Build the tree using the parameters; the resulting tree will be cached.
      $tree[$cid] = $this->menu_build_tree($bid, $tree_parameters);
488 489 490 491 492 493
    }

    return $tree[$cid];
  }

  /**
494
   * {@inheritdoc}
495 496 497 498 499 500 501 502
   */
  public function bookTreeOutput(array $tree) {
    $build = array();
    $items = array();

    // Pull out just the menu links we are going to render so that we
    // get an accurate count for the first/last classes.
    foreach ($tree as $data) {
503
      if ($data['link']['access']) {
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535
        $items[] = $data;
      }
    }

    $num_items = count($items);
    foreach ($items as $i => $data) {
      $class = array();
      if ($i == 0) {
        $class[] = 'first';
      }
      if ($i == $num_items - 1) {
        $class[] = 'last';
      }
      // 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
      // the current menu.
      if ($data['link']['has_children'] && $data['below']) {
        $class[] = 'expanded';
      }
      elseif ($data['link']['has_children']) {
        $class[] = 'collapsed';
      }
      else {
        $class[] = 'leaf';
      }
      // Set a class if the link is in the active trail.
      if ($data['link']['in_active_trail']) {
        $class[] = 'active-trail';
        $data['link']['localized_options']['attributes']['class'][] = 'active-trail';
      }

      // Allow menu-specific theme overrides.
536
      $element['#theme'] = 'book_link__book_toc_' . $data['link']['bid'];
537 538
      $element['#attributes']['class'] = $class;
      $element['#title'] = $data['link']['title'];
539 540
      $node = \Drupal::entityManager()->getStorageController('node')->load($data['link']['nid']);
      $element['#href'] = $node->url();
541 542 543
      $element['#localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : array();
      $element['#below'] = $data['below'] ? $this->bookTreeOutput($data['below']) : $data['below'];
      $element['#original_link'] = $data['link'];
544 545
      // Index using the link's unique nid.
      $build[$data['link']['nid']] = $element;
546 547 548 549 550 551
    }
    if ($build) {
      // Make sure drupal_render() does not re-order the links.
      $build['#sorted'] = TRUE;
      // Add the theme wrapper for outer markup.
      // Allow menu-specific theme overrides.
552
      $build['#theme_wrappers'][] = 'menu_tree__book_toc_' . $data['link']['nid'];
553 554 555 556 557 558 559 560
    }

    return $build;
  }

  /**
   * Builds a menu tree, translates links, and checks access.
   *
561 562
   * @param int $bid
   *   The Book ID to find links for.
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581
   * @param array $parameters
   *   (optional) An associative array of build parameters. Possible keys:
   *   - expanded: An array of parent link ids to return only menu links that are
   *     children of one of the plids in this list. If empty, the whole menu tree
   *     is built, unless 'only_active_trail' is TRUE.
   *   - active_trail: An array of mlids, representing the coordinates of the
   *     currently active menu 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 menu links in the resulting tree.
   *     Defaults to 1, which is the default to build a whole tree for a menu
   *     (excluding menu container itself).
   *   - max_depth: The maximum depth of menu links in the resulting tree.
   *   - conditions: An associative array of custom database select query
   *     condition key/value pairs; see _menu_build_tree() for the actual query.
   *
   * @return array
   *   A fully built menu tree.
   */
582
  protected function menu_build_tree($bid, array $parameters = array()) {
583
    // Build the menu tree.
584
    $data = $this->_menu_build_tree($bid, $parameters);
585
    // Check access for the current user to each item in the tree.
586
    $this->bookTreeCheckAccess($data['tree'], $data['node_links']);
587 588 589 590 591 592 593 594 595 596 597 598
    return $data['tree'];
  }

  /**
   * Builds a menu tree.
   *
   * 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.
   * menu_tree_check_access() needs to be invoked afterwards.
   *
   * @see menu_build_tree()
   */
599
  protected function _menu_build_tree($bid, array $parameters = array()) {
600
    // Static cache of already built menu trees.
601
    $trees = &drupal_static(__METHOD__, array());
602
    $language_interface = \Drupal::languageManager()->getCurrentLanguage();
603 604 605 606 607 608

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

    // If we do not have this tree in the static cache, check {cache_menu}.
    if (!isset($trees[$tree_cid])) {
613
      $cache = \Drupal::cache('menu')->get($tree_cid);
614 615 616 617 618 619
      if ($cache && isset($cache->data)) {
        $trees[$tree_cid] = $cache->data;
      }
    }

    if (!isset($trees[$tree_cid])) {
620 621 622 623
      $query = $this->connection->select('book');
      $query->fields('book');
      for ($i = 1; $i <= static::BOOK_MAX_DEPTH; $i++) {
        $query->orderBy('p' . $i, 'ASC');
624
      }
625
      $query->condition('bid', $bid);
626
      if (!empty($parameters['expanded'])) {
627
        $query->condition('pid', $parameters['expanded'], 'IN');
628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644
      }
      $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
      if ($min_depth != 1) {
        $query->condition('depth', $min_depth, '>=');
      }
      if (isset($parameters['max_depth'])) {
        $query->condition('depth', $parameters['max_depth'], '<=');
      }
      // Add custom query conditions, if any were passed.
      if (isset($parameters['conditions'])) {
        foreach ($parameters['conditions'] as $column => $value) {
          $query->condition($column, $value);
        }
      }

      // Build an ordered array of links using the query result object.
      $links = array();
645 646 647 648
      $result = $query->execute();
      foreach ($result as $link) {
        $link = (array) $link;
        $links[$link['nid']] = $link;
649 650 651 652 653 654 655
      }
      $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
      $data['tree'] = $this->menu_tree_data($links, $active_trail, $min_depth);
      $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('menu')->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
      if ($v['link']['nid']) {
        $nid = $v['link']['nid'];
        $node_links[$nid][$tree[$key]['link']['nid']] = &$tree[$key]['link'];
673 674 675 676 677 678 679 680 681
        $tree[$key]['link']['access'] = FALSE;
      }
      if ($tree[$key]['below']) {
        $this->bookTreeCollectNodeLinks($tree[$key]['below'], $node_links);
      }
    }
  }

  /**
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 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 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754
   * {@inheritdoc}
   */
  public function loadBookLink($nid, $translate = TRUE) {
    $link = $this->connection->query("SELECT * FROM {book} WHERE nid = :nid", array(':nid' => $nid))->fetchAssoc();
    if ($link && $translate) {
      $this->bookLinkTranslate($link);
    }
    return $link;
  }

  /**
   * {@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.
      $this->connection->insert('book')
        ->fields(array(
            'nid' => $link['nid'],
            'bid' => $link['bid'],
            'pid' => $link['pid'],
            'weight' => $link['weight'],
          ) + $this->getBookParents($link, (array) $this->loadBookLink($link['pid'], FALSE)))
        ->execute();
      // 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.
      $query = $this->connection->update('book');
      $query->fields(array('weight' => $link['weight'], 'pid' => $link['pid'], 'bid' => $link['bid']));
      $query->condition('nid', $link['nid']);
      $query->execute();
    }
    foreach ($affected_bids as $bid) {
      \Drupal::cache('menu')->deleteTags(array('bid' => $bid));
    }
  }

  /**
   * Moves children from the original parent to the updated link.
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 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 809 810 811 812 813 814 815 816 817 818 819 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 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888
   * @param array $link
   *   The link being saved.
   * @param array $original
   *   The original parent of $link.
   */
  protected function moveChildren(array $link, array $original) {
    $query = $this->connection->update('book');

    $query->fields(array('bid' => $link['bid']));

    $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);
    }
    foreach ($expressions as $expression) {
      $query->expression($expression[0], $expression[1], $expression[2]);
    }

    $query->expression('depth', 'depth + :depth', array(':depth' => $shift));
    $query->condition('bid', $original['bid']);
    $p = 'p1';
    for ($i = 1; !empty($original[$p]); $p = 'p' . ++$i) {
      $query->condition($p, $original[$p]);
    }

    $query->execute();
  }

  /**
   * 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;
    }
    $query = $this->connection->update('book');
    $query->fields(array('has_children' => 1))
      ->condition('nid', $link['pid']);
    return $query->execute();
  }

  /**
   * 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.
    $original_number_of_children = $this->connection->select('book', 'b')
      ->condition('bid', $original['bid'])
      ->condition('pid', $original['pid'])
      ->condition('nid', $original['nid'], '<>')
      ->countQuery()
      ->execute()
      ->fetchField();

    $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).
    $query = $this->connection->update('book');
    $query->fields(array('has_children' => $parent_has_children))
        ->condition('nid', $original['pid']);
    return $query->execute();
  }

  /**
   * 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}
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917
   */
  public function bookTreeCheckAccess(&$tree, $node_links = array()) {
    if ($node_links) {
      $nids = array_keys($node_links);
      $select = db_select('node_field_data', 'n');
      $select->addField('n', 'nid');
      // @todo This should be actually filtering on the desired node status field
      //   language and just fall back to the default language.
      $select->condition('n.status', 1);

      $select->condition('n.nid', $nids, 'IN');
      $select->addTag('node_access');
      $nids = $select->execute()->fetchCol();
      foreach ($nids as $nid) {
        foreach ($node_links[$nid] as $mlid => $link) {
          $node_links[$nid][$mlid]['access'] = TRUE;
        }
      }
    }
    $this->_menu_tree_check_access($tree);
  }

  /**
   * Sorts the menu tree and recursively checks access for each item.
   */
  protected function _menu_tree_check_access(&$tree) {
    $new_tree = array();
    foreach ($tree as $key => $v) {
      $item = &$tree[$key]['link'];
918
      $this->bookLinkTranslate($item);
919 920 921 922 923 924 925
      if ($item['access']) {
        if ($tree[$key]['below']) {
          $this->_menu_tree_check_access($tree[$key]['below']);
        }
        // The weights are made a uniform 5 digits by adding 50000 as an offset.
        // After _menu_link_translate(), $item['title'] has the localized link title.
        // Adding the mlid to the end of the index insures that it is unique.
926
        $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['nid']] = $tree[$key];
927 928 929 930 931 932 933 934
      }
    }
    // Sort siblings in the tree based on the weights and localized titles.
    ksort($new_tree);
    $tree = $new_tree;
  }

  /**
935
   * {@inheritdoc}
936
   */
937 938 939 940 941 942
  public function bookLinkTranslate(&$link) {
    $node = NULL;
    // Access will already be set in the tree functions.
    if (!isset($link['access'])) {
      $node = $this->entityManager->getStorageController('node')->load($link['nid']);
      $link['access'] = $node && $node->access('view');
943 944
    }
    // For performance, don't localize a link the user can't access.
945 946 947 948 949
    if ($link['access']) {
      // @todo - load the nodes en-mass rather than individually.
      if (!$node) {
        $node = $this->entityManager->getStorageController('node')
          ->load($link['nid']);
950
      }
951 952 953
      // The node label will be the value for the current user's language.
      $link['title'] = $node->label();
      $link['options'] = array();
954
    }
955
    return $link;
956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000
  }

  /**
   * Sorts and returns the built data representing a menu tree.
   *
   * @param array $links
   *   A flat array of menu links that are part of the menu. Each array element
   *   is an associative array of information about the menu link, containing the
   *   fields from the {menu_links} table, and optionally additional information
   *   from the {menu_router} table, if the menu item appears in both tables.
   *   This array must be ordered depth-first. See _menu_build_tree() for a sample
   *   query.
   * @param array $parents
   *   An array of the menu link ID values that are in the path from the current
   *   page to the root of the menu tree.
   * @param int $depth
   *   The minimum depth to include in the returned menu tree.
   *
   * @return array
   *   An array of menu links in the form of a tree. Each item in the tree is an
   *   associative array containing:
   *   - link: The menu link item from $links, with additional element
   *     'in_active_trail' (TRUE if the link ID was in $parents).
   *   - 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 menu item has no items in its sub-tree having a depth
   *     greater than or equal to $depth.
   */
  protected function menu_tree_data(array $links, array $parents = array(), $depth = 1) {
    // Reverse the array so we can use the more efficient array_pop() function.
    $links = array_reverse($links);
    return $this->_menu_tree_data($links, $parents, $depth);
  }

  /**
   * Builds the data representing a menu tree.
   *
   * The function is a bit complex because the rendering of a link depends on
   * the next menu link.
   */
  protected function _menu_tree_data(&$links, $parents, $depth) {
    $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.
1001
      $item['in_active_trail'] = in_array($item['nid'], $parents);
1002
      // Add the current link to the tree.
1003
      $tree[$item['nid']] = array(
1004 1005 1006 1007 1008 1009 1010 1011 1012
        'link' => $item,
        'below' => array(),
      );
      // 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.
      $next = end($links);
      // Check whether the next link is the first in a new sub-tree.
      if ($next && $next['depth'] > $depth) {
        // Recursively call _menu_tree_data to build the sub-tree.
1013
        $tree[$item['nid']]['below'] = $this->_menu_tree_data($links, $parents, $next['depth']);
1014 1015 1016
        // Fetch next link after filling the sub-tree.
        $next = end($links);
      }
1017
      // Determine if we should exit the loop and $request = return.
1018 1019 1020 1021 1022 1023 1024 1025
      if (!$next || $next['depth'] < $depth) {
        break;
      }
    }
    return $tree;
  }

  /**
1026
   * {@inheritdoc}
1027 1028
   */
  public function bookMenuSubtreeData($link) {
1029
    $tree = &drupal_static(__METHOD__, array());
1030

1031 1032
    // Generate a cache ID (cid) specific for this $link.
    $cid = 'book-links:subtree-cid:' . $link['nid'];
1033 1034

    if (!isset($tree[$cid])) {
1035
      $cache = \Drupal::cache('menu')->get($cid);
1036 1037 1038 1039

      if ($cache && isset($cache->data)) {
        // If the cache entry exists, it will just be the cid for the actual data.
        // This avoids duplication of large amounts of data.
1040
        $cache = \Drupal::cache('menu')->get($cache->data);
1041 1042 1043 1044 1045 1046 1047 1048

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

      // If the subtree data was not in the cache, $data will be NULL.
      if (!isset($data)) {
1049
        $query = db_select('book', 'b', array('fetch' => \PDO::FETCH_ASSOC));
1050
        $query->fields('b');
1051 1052
        $query->condition('b.bid', $link['bid']);
        for ($i = 1; $i <= static::BOOK_MAX_DEPTH && $link["p$i"]; ++$i) {
1053 1054
          $query->condition("p$i", $link["p$i"]);
        }
1055
        for ($i = 1; $i <= static::BOOK_MAX_DEPTH; ++$i) {
1056 1057 1058 1059 1060 1061 1062 1063 1064 1065
          $query->orderBy("p$i");
        }
        $links = array();
        foreach ($query->execute() as $item) {
          $links[] = $item;
        }
        $data['tree'] = $this->menu_tree_data($links, array(), $link['depth']);
        $data['node_links'] = array();
        $this->bookTreeCollectNodeLinks($data['tree'], $data['node_links']);
        // Compute the real cid for book subtree data.
1066
        $tree_cid = 'book-links:subtree-data:' . hash('sha256', serialize($data));
1067 1068
        // Cache the data, if it is not already in the cache.

1069
        if (!\Drupal::cache('menu')->get($tree_cid)) {
1070
          \Drupal::cache('menu')->set($tree_cid, $data, Cache::PERMANENT, array('bid' => $link['bid']));
1071 1072
        }
        // Cache the cid of the (shared) data using the menu and item-specific cid.
1073
        \Drupal::cache('menu')->set($cid, $tree_cid, Cache::PERMANENT, array('bid' => $link['bid']));
1074 1075 1076 1077 1078 1079 1080 1081
      }
      // 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];
  }
1082

1083
}