book.module 22.6 KB
Newer Older
1 2
<?php

3 4
/**
 * @file
5
 * Allows users to create and organize related content in an outline.
6 7
 */

8
use Drupal\book\BookManager;
9
use Drupal\book\BookManagerInterface;
10
use Drupal\Component\Utility\String;
11
use Drupal\Core\Entity\EntityInterface;
12
use Drupal\Core\Form\FormStateInterface;
13
use Drupal\Core\Render\Element;
14
use Drupal\Core\Routing\RouteMatchInterface;
15
use Drupal\Core\Url;
16
use Drupal\node\NodeInterface;
17
use Drupal\node\NodeTypeInterface;
18
use Drupal\node\Entity\Node;
19
use Drupal\Core\Language\LanguageInterface;
20
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
21
use Drupal\Core\Template\Attribute;
22
use Drupal\Core\Extension\Extension;
23

24
/**
25
 * Implements hook_help().
26
 */
27
function book_help($route_name, RouteMatchInterface $route_match) {
28 29
  switch ($route_name) {
    case 'help.page.book':
30
      $output = '<h3>' . t('About') . '</h3>';
31
      $output .= '<p>' . t('The Book module is used for creating structured, multi-page content, such as site resource guides, manuals, and wikis. It allows you to create content that has chapters, sections, subsections, or any similarly-tiered structure. For more information, see the <a href="!book">online documentation for the Book module</a>.', array('!book' => 'https://drupal.org/documentation/modules/book')) . '</p>';
32 33 34
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
      $output .= '<dt>' . t('Adding and managing book content') . '</dt>';
35
      $output .= '<dd>' . t('You can assign separate permissions for <em>creating new books</em> as well as <em>creating</em>, <em>editing</em> and <em>deleting</em> book content. Users with the <em>Administer book outlines</em> permission can add <em>any</em> type of content to a book by selecting the appropriate book outline while editing the content. They can also view a list of all books, and edit and rearrange section titles on the <a href="!admin-book">Book administration page</a>.', array('!admin-book' => \Drupal::url('book.admin'))) . '</dd>';
36
      $output .= '<dt>' . t('Book navigation') . '</dt>';
37
      $output .= '<dd>' . t("Book pages have a default book-specific navigation block. This navigation block contains links that lead to the previous and next pages in the book, and to the level above the current page in the book's structure. This block can be enabled on the <a href='!admin-block'>Blocks administration page</a>. For book pages to show up in the book navigation, they must be added to a book outline.", array('!admin-block' => \Drupal::url('block.admin_display'))) . '</dd>';
38
      $output .= '<dt>' . t('Collaboration') . '</dt>';
39
      $output .= '<dd>' . t('Books can be created collaboratively, as they allow users with appropriate permissions to add pages into existing books, and add those pages to a custom table of contents.') . '</dd>';
40
      $output .= '<dt>' . t('Printing books') . '</dt>';
41
      $output .= '<dd>' . t("Users with the <em>View printer-friendly books</em> permission can select the <em>printer-friendly version</em> link visible at the bottom of a book page's content to generate a printer-friendly display of the page and all of its subsections.") . '</dd>';
42 43
      $output .= '</dl>';
      return $output;
44 45

    case 'book.admin':
46
      return '<p>' . t('The book module offers a means to organize a collection of related content pages, collectively known as a book. When viewed, this content automatically displays links to adjacent book pages, providing a simple navigation system for creating and reviewing structured content.') . '</p>';
47

48
    case 'entity.node.book_outline_form':
49
      return '<p>' . t('The outline feature allows you to include pages in the <a href="!book">Book hierarchy</a>, as well as move them within the hierarchy or to <a href="!book-admin">reorder an entire book</a>.', array('!book' => \Drupal::url('book.render'), '!book-admin' => \Drupal::url('book.admin'))) . '</p>';
50 51 52
  }
}

53
/**
54
 * Implements hook_theme().
55 56 57 58
 */
function book_theme() {
  return array(
    'book_navigation' => array(
59
      'variables' => array('book_link' => NULL),
60
    ),
61 62 63
    'book_tree' => array(
      'render element' => 'tree',
    ),
64 65
    'book_link' => array(
      'render element' => 'element',
66
      'function' => 'theme_book_link',
67
    ),
68
    'book_export_html' => array(
69
      'variables' => array('title' => NULL, 'contents' => NULL, 'depth' => NULL),
70 71
    ),
    'book_admin_table' => array(
72
      'render element' => 'form',
73
      'file' => 'book.admin.inc',
74
      'function' => 'theme_book_admin_table',
75
    ),
Dries's avatar
Dries committed
76
    'book_all_books_block' => array(
77
      'render element' => 'book_menus',
78 79
    ),
    'book_node_export_html' => array(
80
      'variables' => array('node' => NULL, 'children' => NULL),
Dries's avatar
Dries committed
81
    ),
82 83 84
  );
}

85
/**
86
 * Implements hook_entity_type_build().
87
 */
88 89 90
function book_entity_type_build(array &$entity_types) {
  /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */
  $entity_types['node']
91
    ->setFormClass('book_outline', 'Drupal\book\Form\BookOutlineForm')
92 93
    ->setLinkTemplate('book-outline-form', '/node/{node}/outline')
    ->setLinkTemplate('book-remove-form', '/node/{node}/outline/remove');
94 95
}

96
/**
97
 * Implements hook_node_links_alter().
98
 */
99 100 101 102 103 104 105
function book_node_links_alter(array &$node_links, NodeInterface $node, array &$context) {
  if ($context['view_mode'] != 'rss') {
    $account = \Drupal::currentUser();

    if (isset($node->book['depth'])) {
      if ($context['view_mode'] == 'full' && node_is_page($node)) {
        $child_type = \Drupal::config('book.settings')->get('child_type');
106 107
        $access_control_handler = \Drupal::entityManager()->getAccessControlHandler('node');
        if (($account->hasPermission('add content to books') || $account->hasPermission('administer book outlines')) && $access_control_handler->createAccess($child_type) && $node->isPublished() && $node->book['depth'] < BookManager::BOOK_MAX_DEPTH) {
108 109
          $links['book_add_child'] = array(
            'title' => t('Add child page'),
110
            'url' => Url::fromRoute('node.add', ['node_type' => $child_type], ['query' => ['parent' => $node->id()]]),
111 112 113 114 115 116
          );
        }

        if ($account->hasPermission('access printer-friendly version')) {
          $links['book_printer'] = array(
            'title' => t('Printer-friendly version'),
117 118 119 120
            'url' => Url::fromRoute('book.export', [
              'type' => 'html',
              'node' => $node->id(),
            ]),
121 122 123
            'attributes' => array('title' => t('Show a printer-friendly version of this book page and its sub-pages.'))
          );
        }
124
      }
125
    }
126

127 128 129 130 131 132 133
    if (!empty($links)) {
      $node_links['book'] = array(
        '#theme' => 'links__node__book',
        '#links' => $links,
        '#attributes' => array('class' => array('links', 'inline')),
      );
    }
134
  }
135 136
}

Dries's avatar
Dries committed
137
/**
138
 * Implements hook_form_BASE_FORM_ID_alter() for node_form().
139
 *
140
 * Adds the book form element to the node form.
141 142
 *
 * @see book_pick_book_nojs_submit()
Dries's avatar
Dries committed
143
 */
144
function book_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) {
145
  $node = $form_state->getFormObject()->getEntity();
146
  $account = \Drupal::currentUser();
147
  $access = $account->hasPermission('administer book outlines');
148
  if (!$access) {
149
    if ($account->hasPermission('add content to books') && ((!empty($node->book['bid']) && !$node->isNew()) || book_type_is_allowed($node->getType()))) {
150 151
      // Already in the book hierarchy, or this node type is allowed.
      $access = TRUE;
Dries's avatar
Dries committed
152
    }
153
  }
Dries's avatar
Dries committed
154

155
  if ($access) {
156 157
    $collapsed = !($node->isNew() && !empty($node->book['pid']));
    $form = \Drupal::service('book.manager')->addFormElements($form, $form_state, $node, $account, $collapsed);
158
    // Since the "Book" dropdown can't trigger a form submission when
159
    // JavaScript is disabled, add a submit button to do that. book.admin.css hides
160 161 162 163 164 165
    // this button when JavaScript is enabled.
    $form['book']['pick-book'] = array(
      '#type' => 'submit',
      '#value' => t('Change book (update list of parents)'),
      '#submit' => array('book_pick_book_nojs_submit'),
      '#weight' => 20,
166 167 168 169 170
      '#attached' => [
        'library' => [
          'book/admin',
        ],
      ],
171
    );
172
    $form['#entity_builders'][] = 'book_node_builder';
173
  }
Dries's avatar
Dries committed
174
}
175

176 177 178 179 180
/**
 * Entity form builder to add the book information to the node.
 *
 * @todo: Remove this in favor of an entity field.
 */
181
function book_node_builder($entity_type, NodeInterface $entity, &$form, FormStateInterface $form_state) {
182
  $entity->book = $form_state->getValue('book');
183 184 185 186 187

  // Always save a revision for non-administrators.
  if (!empty($entity->book['bid']) && !\Drupal::currentUser()->hasPermission('administer nodes')) {
    $entity->setNewRevision();
  }
188 189
}

190
/**
191
 * Form submission handler for node_form().
192 193 194 195 196
 *
 * This handler is run when JavaScript is disabled. It triggers the form to
 * rebuild so that the "Parent item" options are changed to reflect the newly
 * selected book. When JavaScript is enabled, the submit button that triggers
 * this handler is hidden, and the "Book" dropdown directly triggers the
197
 * book_form_update() Ajax callback instead.
198 199
 *
 * @see book_form_update()
200
 * @see book_form_node_form_alter()
201
 */
202
function book_pick_book_nojs_submit($form, FormStateInterface $form_state) {
203
  $node = $form_state->getFormObject()->getEntity();
204
  $node->book = $form_state->getValue('book');
205
  $form_state->setRebuild();
206 207
}

208 209 210
/**
 * Renders a new parent page select element when the book selection changes.
 *
211
 * This function is called via Ajax when the selected book is changed on a node
212 213 214 215 216
 * or book outline form.
 *
 * @return
 *   The rendered parent page select element.
 */
217
function book_form_update($form, FormStateInterface $form_state) {
218
  return $form['book']['pid'];
219 220
}

221
/**
222
 * Implements hook_ENTITY_TYPE_load() for node entities.
223
 */
224
function book_node_load($nodes) {
225 226 227 228
  /** @var \Drupal\book\BookManagerInterface $book_manager */
  $book_manager = \Drupal::service('book.manager');
  $links = $book_manager->loadBookLinks(array_keys($nodes), FALSE);
  foreach ($links as $record) {
229
    $nodes[$record['nid']]->book = $record;
230 231
    $nodes[$record['nid']]->book['link_path'] = 'node/' . $record['nid'];
    $nodes[$record['nid']]->book['link_title'] = $nodes[$record['nid']]->label();
232 233
  }
}
234

235
/**
236
 * Implements hook_ENTITY_TYPE_view() for node entities.
237
 */
238
function book_node_view(array &$build, EntityInterface $node, EntityViewDisplayInterface $display, $view_mode) {
239
  if ($view_mode == 'full') {
240
    if (!empty($node->book['bid']) && empty($node->in_preview)) {
241 242 243 244
      $book_node = Node::load($node->book['bid']);
      if (!$book_node->access()) {
        return;
      }
245
      $book_navigation = array( '#theme' => 'book_navigation', '#book_link' => $node->book);
246
      $build['book_navigation'] = array(
247
        '#markup' => drupal_render($book_navigation),
248
        '#weight' => 100,
249 250 251 252 253
        '#attached' => [
          'library' => [
            'book/navigation',
          ],
        ],
254 255 256 257 258
        // The book navigation is a listing of Node entities, so associate its
        // list cache tag for correct invalidation.
        '#cache' => [
          'tags' => $node->getEntityType()->getListCacheTags(),
        ],
259 260 261
      );
    }
  }
262 263
}

264
/**
265
 * Implements hook_ENTITY_TYPE_presave() for node entities.
266
 */
267
function book_node_presave(EntityInterface $node) {
268
  // Make sure a new node gets a new menu link.
269
  if ($node->isNew()) {
270
    $node->book['nid'] = NULL;
271 272
  }
}
273

274
/**
275
 * Implements hook_ENTITY_TYPE_insert() for node entities.
276
 */
277
function book_node_insert(EntityInterface $node) {
278
  /** @var \Drupal\book\BookManagerInterface $book_manager */
279
  $book_manager = \Drupal::service('book.manager');
280
  $book_manager->updateOutline($node);
281
}
282

283
/**
284
 * Implements hook_ENTITY_TYPE_update() for node entities.
285
 */
286
function book_node_update(EntityInterface $node) {
287
  /** @var \Drupal\book\BookManagerInterface $book_manager */
288
  $book_manager = \Drupal::service('book.manager');
289
  $book_manager->updateOutline($node);
290
}
291

292
/**
293
 * Implements hook_ENTITY_TYPE_predelete() for node entities.
294
 */
295
function book_node_predelete(EntityInterface $node) {
296
  if (!empty($node->book['bid'])) {
297 298 299
    /** @var \Drupal\book\BookManagerInterface $book_manager */
    $book_manager = \Drupal::service('book.manager');
    $book_manager->deleteFromBook($node->book['nid']);
300 301 302 303
  }
}

/**
304
 * Implements hook_node_prepare_form().
305
 */
306
function book_node_prepare_form(NodeInterface $node, $operation, FormStateInterface $form_state) {
307
  /** @var \Drupal\book\BookManagerInterface $book_manager */
308
  $book_manager = \Drupal::service('book.manager');
309

310
  // Prepare defaults for the add/edit form.
311
  $account = \Drupal::currentUser();
312
  if (empty($node->book) && ($account->hasPermission('add content to books') || $account->hasPermission('administer book outlines'))) {
313 314
    $node->book = array();

315
    $query = \Drupal::request()->query;
316
    if ($node->isNew() && !is_null($query->get('parent')) && is_numeric($query->get('parent'))) {
317
      // Handle "Add child page" links:
318
      $parent = $book_manager->loadBookLink($query->get('parent'), TRUE);
319 320 321

      if ($parent && $parent['access']) {
        $node->book['bid'] = $parent['bid'];
322
        $node->book['pid'] = $parent['nid'];
323
      }
324 325
    }
    // Set defaults.
326 327
    $node_ref = !$node->isNew() ? $node->id() : 'new';
    $node->book += $book_manager->getLinkDefaults($node_ref);
328 329 330 331 332 333 334 335
  }
  else {
    if (isset($node->book['bid']) && !isset($node->book['original_bid'])) {
      $node->book['original_bid'] = $node->book['bid'];
    }
  }
  // Find the depth limit for the parent select.
  if (isset($node->book['bid']) && !isset($node->book['parent_depth_limit'])) {
336
    $node->book['parent_depth_limit'] = $book_manager->getParentDepthLimit($node->book);
Dries's avatar
Dries committed
337 338 339 340
  }
}

/**
341
 * Implements hook_form_FORM_ID_alter() for node_delete_confirm().
342 343 344 345
 *
 * Alters the confirm form for a single node deletion.
 *
 * @see node_delete_confirm()
Dries's avatar
Dries committed
346
 */
347
function book_form_node_delete_confirm_alter(&$form, FormStateInterface $form_state) {
348
  $node = Node::load($form['nid']['#value']);
Dries's avatar
Dries committed
349 350 351

  if (isset($node->book) && $node->book['has_children']) {
    $form['book_warning'] = array(
352
      '#markup' => '<p>' . t('%title is part of a book outline, and has associated child pages. If you proceed with deletion, the child pages will be relocated automatically.', array('%title' => $node->label())) . '</p>',
Dries's avatar
Dries committed
353 354
      '#weight' => -10,
    );
355
  }
356
}
Dries's avatar
Dries committed
357

358
/**
359 360 361
 * Prepares variables for book listing block templates.
 *
 * Default template: book-all-books-block.html.twig.
362
 *
363 364 365
 * All non-renderable elements are removed so that the template has full access
 * to the structured data but can also simply iterate over all elements and
 * render them (as in the default template).
366
 *
367 368
 * @param array $variables
 *   An associative array containing the following key:
369 370
 *   - book_menus: An associative array containing renderable menu links for all
 *     book menus.
371 372 373 374 375
 */
function template_preprocess_book_all_books_block(&$variables) {
  // Remove all non-renderable elements.
  $elements = $variables['book_menus'];
  $variables['book_menus'] = array();
376
  foreach (Element::children($elements) as $index) {
377 378 379 380 381
    $variables['book_menus'][] = array(
      'id' => $index,
      'menu' => $elements[$index],
      'title' => $elements[$index]['#book_title'],
    );
382 383 384
  }
}

Dries's avatar
Dries committed
385
/**
386 387 388
 * Prepares variables for book navigation templates.
 *
 * Default template: book-navigation.html.twig.
Dries's avatar
Dries committed
389
 *
390 391
 * @param array $variables
 *   An associative array containing the following key:
392
 *   - book_link: An associative array of book link properties.
393
 *     Properties used: bid, link_title, depth, pid, nid.
394
 */
395 396 397 398 399
function template_preprocess_book_navigation(&$variables) {
  $book_link = $variables['book_link'];

  // Provide extra variables for themers. Not needed by default.
  $variables['book_id'] = $book_link['bid'];
400
  $variables['book_title'] = String::checkPlain($book_link['link_title']);
401
  $variables['book_url'] = \Drupal::url('entity.node.canonical', array('node' => $book_link['bid']));
402 403
  $variables['current_depth'] = $book_link['depth'];
  $variables['tree'] = '';
404

405 406 407
  /** @var \Drupal\book\BookOutline $book_outline */
  $book_outline = \Drupal::service('book.outline');

408
  if ($book_link['nid']) {
409
    $variables['tree'] = $book_outline->childrenLinks($book_link);
410

411 412
    $build = array();

413
    if ($prev = $book_outline->prevLink($book_link)) {
414
      $prev_href = \Drupal::url('entity.node.canonical', array('node' => $prev['nid']));
415
      $build['#attached']['html_head_link'][][] = array(
416 417 418
        'rel' => 'prev',
        'href' => $prev_href,
      );
419
      $variables['prev_url'] = $prev_href;
420
      $variables['prev_title'] = String::checkPlain($prev['title']);
421
    }
422

423 424 425
    /** @var \Drupal\book\BookManagerInterface $book_manager */
    $book_manager = \Drupal::service('book.manager');
    if ($book_link['pid'] && $parent = $book_manager->loadBookLink($book_link['pid'])) {
426
      $parent_href = \Drupal::url('entity.node.canonical', array('node' => $book_link['pid']));
427
      $build['#attached']['html_head_link'][][] = array(
428 429 430
        'rel' => 'up',
        'href' => $parent_href,
      );
431
      $variables['parent_url'] = $parent_href;
432
      $variables['parent_title'] = String::checkPlain($parent['title']);
433
    }
434

435
    if ($next = $book_outline->nextLink($book_link)) {
436
      $next_href = \Drupal::url('entity.node.canonical', array('node' => $next['nid']));
437
      $build['#attached']['html_head_link'][][] = array(
438 439 440
        'rel' => 'next',
        'href' => $next_href,
      );
441
      $variables['next_url'] = $next_href;
442
      $variables['next_title'] = String::checkPlain($next['title']);
443
    }
444
  }
445

446 447 448 449
  if (!empty($build)) {
    drupal_render($build);
  }

450 451 452 453 454 455 456 457 458 459 460
  $variables['has_links'] = FALSE;
  // Link variables to filter for values and set state of the flag variable.
  $links = array('prev_url', 'prev_title', 'parent_url', 'parent_title', 'next_url', 'next_title');
  foreach ($links as $link) {
    if (isset($variables[$link])) {
      // Flag when there is a value.
      $variables['has_links'] = TRUE;
    }
    else {
      // Set empty to prevent notices.
      $variables[$link] = '';
461
    }
462
  }
463
}
464

465
/**
466
 * Prepares variables for book export templates.
467
 *
468
 * Default template: book-export-html.html.twig.
469
 *
470 471 472 473 474
 * @param array $variables
 *   An associative array containing:
 *   - title: The title of the book.
 *   - contents: Output of each book page.
 *   - depth: The max depth of the book.
475 476
 */
function template_preprocess_book_export_html(&$variables) {
477
  global $base_url;
478
  $language_interface = \Drupal::languageManager()->getCurrentLanguage();
479

480
  $variables['title'] = String::checkPlain($variables['title']);
481
  $variables['base_url'] = $base_url;
482
  $variables['language'] = $language_interface;
483
  $variables['language_rtl'] = ($language_interface->getDirection() == LanguageInterface::DIRECTION_RTL);
484
  $variables['head'] = drupal_get_html_head();
485 486

  // HTML element attributes.
487
  $attributes = array();
488
  $attributes['lang'] = $language_interface->getId();
489
  $attributes['dir'] = $language_interface->getDirection();
490
  $variables['html_attributes'] = new Attribute($attributes);
491 492
}

493
/**
494 495 496
 * Prepares variables for single node export templates.
 *
 * Default template: book-node-export-html.html.twig.
Dries's avatar
Dries committed
497
 *
498 499
 * @param array $variables
 *   An associative array containing the following keys:
500 501 502
 *   - node: The node that will be output.
 *   - children: All the rendered child nodes within the current node. Defaults
 *     to an empty string.
503
 */
504 505
function template_preprocess_book_node_export_html(&$variables) {
  $variables['depth'] = $variables['node']->book['depth'];
506
  $variables['title'] = String::checkPlain($variables['node']->label());
507
  $variables['content'] = $variables['node']->rendered;
508 509
}

510
/**
511
 * Implements template_preprocess_HOOK() for book-tree.html.twig.
512 513 514 515 516
 */
function template_preprocess_book_tree(&$variables) {
  $variables['tree'] = $variables['tree']['#children'];
}

517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
/**
 * Returns HTML for a book link and subtree.
 *
 * @param array $variables
 *   An associative array containing:
 *   - element: Structured array data for a book link.
 *
 * @ingroup themeable
 */
function theme_book_link(array $variables) {
  $element = $variables['element'];
  $sub_menu = '';

  if ($element['#below']) {
    $sub_menu = drupal_render($element['#below']);
  }
  $element['#localized_options']['set_active_class'] = TRUE;
534 535 536
  /** @var \Drupal\Core\Url $url */
  $url = $element['#url'];
  $url->setOptions($element['#localized_options'] + $url->getOptions());
537
  $output = \Drupal::l($element['#title'], $url);
538 539 540
  return '<li' . new Attribute($element['#attributes']) . '>' . $output . $sub_menu . "</li>\n";
}

541
/**
542
 * Determines if a given node type is in the list of types allowed for books.
543 544 545 546 547 548
 *
 * @param string $type
 *   A node type.
 *
 * @return bool
 *   A Boolean TRUE if the node type can be included in books; otherwise, FALSE.
549
 */
Dries's avatar
Dries committed
550
function book_type_is_allowed($type) {
551
  return in_array($type, \Drupal::config('book.settings')->get('allowed_types'));
552 553
}

Dries's avatar
Dries committed
554
/**
555
 * Implements hook_ENTITY_TYPE_update() for node_type entities.
Dries's avatar
Dries committed
556
 *
557 558
 * Updates book.settings configuration object if the machine-readable name of a
 * node type is changed.
Dries's avatar
Dries committed
559
 */
560
function book_node_type_update(NodeTypeInterface $type) {
561
  if ($type->getOriginalId() != $type->id()) {
562
    $config = \Drupal::configFactory()->getEditable('book.settings');
563
    // Update the list of node types that are allowed to be added to books.
564
    $allowed_types = $config->get('allowed_types');
565
    $old_key = array_search($type->getOriginalId(), $allowed_types);
566

567
    if ($old_key !== FALSE) {
568
      $allowed_types[$old_key] = $type->id();
569
      // Ensure that the allowed_types array is sorted consistently.
570
      // @see BookSettingsForm::submitForm()
571
      sort($allowed_types);
572
      $config->set('allowed_types', $allowed_types);
573 574 575
    }

    // Update the setting for the "Add child page" link.
576
    if ($config->get('child_type') == $type->getOriginalId()) {
577
      $config->set('child_type', $type->id());
578
    }
579
    $config->save();
Dries's avatar
Dries committed
580
  }
Dries's avatar
Dries committed
581
}
582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611

/**
 * Implements hook_system_info_alter().
 *
 * Prevents book module from being uninstalled whilst any book nodes exist or
 * there are any book outline stored.
 */
function book_system_info_alter(&$info, Extension $file, $type) {
  // It is not safe use the entity query service during maintenance mode.
  if ($type == 'module' && !defined('MAINTENANCE_MODE') && $file->getName() == 'book') {
    if (\Drupal::service('book.outline_storage')->hasBooks()) {
      $info['required'] = TRUE;
      $info['explanation'] = t('To uninstall Book, delete all content that is part of a book.');
    }
    else {
      // The book node type is provided by the Book module. Prevent uninstall if
      // there are any nodes of that type.
      $factory = \Drupal::service('entity.query');
      $nodes = $factory->get('node')
        ->condition('type', 'book')
        ->accessCheck(FALSE)
        ->range(0, 1)
        ->execute();
      if (!empty($nodes)) {
        $info['required'] = TRUE;
        $info['explanation'] = t('To uninstall Book, delete all content that has the Book content type.');
      }
    }
  }
}