Last updated December 22, 2009. Created on January 14, 2006.
Edited by kenorb, coreyp_1, sepeck, cel4145. Log in to edit this page.

description

This snippet will create a book navigation menu that can be present on your site any time, not just when navigating a book. It has three configurable features:

  1. $book_top_page is the node number which represents the top level of your book. Since Drupal and the book module support multiple books, you must supply the desired node number for the script to follow. You can set this to any book page number, even nodes in the middle of a book.
  2. $levels_deep is the number of levels you want to show (pre-expanded, so to speak) in the menu. "0" will result in nothing being shown, so don't use it. "1" will show one level, "2" will show two levels deep, etc.
  3. $emulate_book_block determines how the code will emulate the book menu.
    • If $emulate_book_block is set to FALSE, then the menu will not expand to show child pages beyond the number of levels specified by $levels_deep.
    • If $emulate_book_block is set to TRUE, then the menu will expand to show the necessary pages.
    • If $emulate_book_block is set to a number (ex. 1, 2, 3, etc.), then the block will expand beyond $levels_deep, but it will limit itself accordingly.

To behave exactly like the book block, set $levels_deep to "1", and $emulate_book_block to TRUE.

To have a book block that will only expand to the second level, set $emulate_book_block to 2.

usage suggestions

If you put this into a custom block, be sure to disable the default book block. Nothing bad will happen if you don't, but it might look odd to have two book navigation menus showing at the same time!

Placed in a page, this could easily provide a complete look at a book's entire hierarchy.

code

Because of significant changes to core APIs, the script is different for different versions of Drupal.

start here

All versions begin with this portion of code:

$book_top_page = 1;
$levels_deep = 1;
$emulate_book_block = true;

if (!function_exists('book_struct_recurse')){
  // we wrap the function in this if() statment to avoid PHP errors
  // when this is used in more than one block
  function book_struct_recurse($nid, $levels_deep, $children, $current_lineage = array(), $emulate_book_block = true) {
    $struct = '';
    if ($children[$nid] && ($levels_deep > 0 || ($emulate_book_block && in_array($nid, $current_lineage)))) {
      $struct = '';
      return $struct;
    }
  }
}

Drupal 4.6, Drupal 4.7, and Drupal 5

For Drupal 4.6.x, 4.7.x, and 5.x, you should continue with this code (please note the difference in SQL for 4.6.x):

$current_lineage = array();

// use this version for Drupal 4.6
// $result = db_query(db_rewrite_sql('SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.nid = b.nid WHERE n.status = 1 ORDER BY b.weight, n.title'));

// use this version for Drupal 4.7 and Drupal 5
$result = db_query(db_rewrite_sql('SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.vid = b.vid WHERE n.status = 1 ORDER BY b.weight, n.title'));

while ($node = db_fetch_object($result)) {
  if (!$children[$node->parent]) {
    $children[$node->parent] = array();
  }
  array_push($children[$node->parent], $node);
 
  if (arg(0) == 'node' && is_numeric(arg(1)) && arg(1) == $node->nid) {
    $_temp = book_location($node);
    foreach ($_temp as $key => $val){
      $current_lineage[] = $val->nid;
    }
    $current_lineage[] = arg(1);
  }
}

echo book_struct_recurse($book_top_page, $levels_deep, $children, $current_lineage, $emulate_book_block);

Drupal 6

For Drupal 6.x, use this portion of code instead:

  $book_top_page= YOUR_NID;
  $tree = menu_tree_all_data(book_menu_name($book_top_page));
  print menu_tree_output($tree);

Looking for support? Visit the Drupal.org forums, or join #drupal-support in IRC.

Comments

mnoyes’s picture

I put the suggested code into a custom block which I hoped to use for navigation; but it seems the block only shows up on the book pages. Removing this bit seemed to solve the problem? Is there a better way?

 if (arg(0) == 'node' && is_numeric(arg(1)) && arg(1) == $node->nid) {
    $_temp = book_location($node);
    foreach ($_temp as $key => $val){
      $current_lineage[] = $val->nid;
    }
    $current_lineage[] = arg(1);
  }

(I'm a rank amateur, so don't try this at home!)

jcolbyk’s picture

The sql query in this block will return *all* book nodes for php processing. If you have hundreds/thousands of book nodes, this will lead to all book nodes being processed by the interpreted PHP (not good). Since you've already got the limiting nodeid listed as book_top_page, I think it makes sense to limit the sql query to only returning those nodes which have book_top_page as their parent, e.g.:

...
$bookNodeQuery = 'SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.nid = b.nid AND n.vid = b.vid AND b.parent = ' . $book_top_page . ' WHERE n.status = 1 ORDER BY b.weight, n.title';
$result = db_query(db_rewrite_sql($bookNodeQuery));
...

This adds another benefit, that you can now limit the returned set as well inside the SQL query:

...
$bookNodeQuery = 'SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.nid = b.nid AND n.vid = b.vid AND b.parent = ' . $book_top_page . ' WHERE n.status = 1 ORDER BY b.weight, n.title LIMIT 5';
$result = db_query(db_rewrite_sql($bookNodeQuery));
...

And so on...

btopro’s picture

$bookNodeQuery = 'SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.nid = b.nid AND n.vid = b.vid AND b.parent = ' . $book_top_page . ' WHERE n.status = 1 ORDER BY b.weight, n.title';
$result = db_query(db_rewrite_sql($bookNodeQuery));

This allows for injection and should be replaced with the following:

$bookNodeQuery = 'SELECT n.nid, n.title, b.parent, b.weight FROM {node} n INNER JOIN {book} b ON n.nid = b.nid AND n.vid = b.vid AND b.parent = %d WHERE n.status = 1 ORDER BY b.weight, n.title';
$result = db_query($bookNodeQuery,$book_top_page);

Minor thing but helps keep things closer to standards, esp. since db_query requires this in 6.x and recommends it in 5.x

Code to empower, always.

coreyp_1’s picture

I agree with the potential problem with the SQL, but this solution breaks the script. It does not allow proper creation of the $children array, because of how D5 stores the book information. Limiting the SQL to the top level book results in only one level being printed, ever.

D6 is a little better, in that you can limit it to a specific book, but it is still a lot of information to process with PHP.

The only alternative I can see would be multiple smaller queries with a whole lot of PHP to figure out how to find the right nodes and put them into the array.

Maybe I'll figure out an alternative next time. On the bright side, block caching should be very helpful.

- Corey

mapiedra’s picture

Thank you for this useful snippet. Publishing the sections of a site as a book is much more efficient (for a web site editor) than using the 'page' content type.

As for the line 10 fix, it also applies to the Marinelli theme (http://drupal.org/project/marinelli).

webchick’s picture

In Drupal 5, if all you really want is a list of the top-level sub-pages of a particular book, you can just do this:

print book_tree(123);

(where 123 is the node ID of the top-level book page.)

mimamim’s picture

Hi!
Unfortunately the suggested code does not work for Drupal 6.8

I have written a small custom block that displays the fully expanded menu for the specified book. I'm not sure about checking permissions, though.

$book_top_page=YOURDATA;
$tree = menu_tree_all_data(book_menu_name($book_top_page));
print menu_tree_output($tree);
tom_mm’s picture

Do you think there is a good way to add a link to add a child page to the item in the navigation. That is, from the menu, have the link got to the add new child? I tried to do this in 6.8 using this code:

$options=array();
$options['query']['parent'] .= $node->nid;
$struct .= '<li class="' . $class . '">' . l($node->title, 'node/add/chapter' , $options) . '</li>';

The code looks right and the URL structure is the same as the actual add child. However, the parent nid is way off. Any suggestions?

mrken’s picture

New Drupal user here:

May I ask what the difference is between the contributed snippet and the following?

	echo menu_tree( book_menu_name( $book_top_page ) );

Is it more or less efficient?

kassissieh’s picture

Here is another way to do it -- code adapted from book.module. I wanted to copy book module's style of making the book subject the first item and linking it to the first node of the book.

I created a tiny custom module to hold this function.

I welcome your code improvement suggestions.


/**
 * Displays a specific book navigation block when not in the book
 */
function YOUR_UNIQUE_MODULE_NAME_HERE_block($op = 'list', $delta = 0, $edit = array()) {
  switch ($op) {
    case 'list':
      $block[0]['info'] = t('YOUR_BOOK_BLOCK_NAME_HERE');
      $block[0]['cache'] = BLOCK_CACHE_PER_PAGE | BLOCK_CACHE_PER_ROLE;
      return $block;
    case 'view':
      $block = array();
      $tree = menu_tree_all_data(book_menu_name( YOUR_BOOK_ID_HERE ));
      $data = array_shift($tree);
      $block['subject'] = theme('book_title_link', $data['link']);
      $block['content'] = ($data['below']) ? menu_tree_output($data['below']) : '';
      return $block;
  }
}

dpw’s picture

In Drupal6, the code above still shows unpublished nodes in the menu. Also, I am using the Private module and since the above code creates the tree directly from {menu_links} it doesn't pass through the necessary authentication. Below is my hack to resolve this.

Basically, I create an array of $nid to exclude, then don't populate the $menu array with any of these $nid's.

This isn't very flexible, but maybe it will help someone... or inspire someone to come up with a better solution :-)

To use, basically replace this section of code:

<?php
    $menu = array();
    while ($m = db_fetch_object($result)) {
      $menu[$m->mlid] = $m;
      $menu[$m->mlid]->nid = $nid = (int)substr($m->link_path, 5);
    } 
?>

with this:

<?php
    // don't show non-published nodes
    $result1 = db_query('SELECT nid FROM {node} n WHERE n.status = 0');
    $exclude_nid = array();
    while ($id = db_fetch_object($result1)) {
       $exclude_nid[] = (int)$id->nid;
    }
    
    // don't show private content to anonymous users
    if (!user_is_logged_in()) {
        $result2 = db_query('SELECT nid FROM {private} p WHERE p.private = 1');
        while ($id = db_fetch_object($result2)) {
           $exclude_nid[] = (int)$id->nid;
        }
    }

    $menu = array();
    while ($m = db_fetch_object($result)) {
      $nid = (int)substr($m->link_path, 5);
      if (!in_array($nid, $exclude_nid, false)) {
        $menu[$m->mlid] = $m;
        $menu[$m->mlid]->nid = $nid;
      }
    } 
?>
kenorb’s picture

Where is book_struct_recurse() defined? I can't find it in 6.15

asb’s picture

I'm confused about this snippet; I understand it's purpose in D5, but not for D6. If I enable the Book navigation block in D6 at ./admin/build/block/configure/book/0, it's default behaviour is to show on all pages anyway, so what's the point of the D6 version of this code snippet?

There's even a configuration option in the block configuration page which allows to "display the block on every page", or to "display the block only on book pages" (not exactly like documented at api.drupal.org, but the settings are there anyway).

Greetings, -asb

mErilainen’s picture

It will show the whole book tree in a block.

I want to know how to have the same behavior for D6 like the snippet does for D5. For D6 I cannot show only 1 level deep, it will always show the whole book tree.

Jovean’s picture

It can also be used in a template to display the book nav of a book of your choice. I just now did just that with a views template - because the book nav isn't printed in D6/Views2 (and I couldn't figure out any other way to make that happen).

joebanks’s picture

The code which started this whole thread would be awesome except it does not work in 6.15.

Looking at the code, I can't figure out where the function actually gets called.

Drupal seems to be using some completely unrelated function for printing the book menu. Thus, changing settings like $levels_deep and $emulate_book_block has no effect on the output. Too bad.

Anyone got a hint?

mErilainen’s picture

I was struggling with book navigation and came up with this:

$book_top_page = 8;
$nid = $book_top_page;
if (arg(0) == 'node' && is_numeric(arg(1))) {
  $nid = arg(1);
  $node = node_load($nid);
  if (!$node->book) {
    $nid = $book_top_page;
  }
}

$node = node_load($nid);
$tree = menu_tree_all_data($node->book['menu_name'], $node->book);
// There should only be one element at the top level.
$data = array_shift($tree);
$block['subject'] = theme('book_title_link', $data['link']);
$block['content'] = '
'; $block['content'] .= ($data['below']) ? menu_tree_output($data['below']) : ''; $block['content'] .= '
'; print $block['content'];
oerisen’s picture

I made a few simple additions so that I was able to view the contents of book pages below the current node
here is what I had done

<?php
$nid = $book_top_page;
if (arg(0) == 'node' && is_numeric(arg(1))) {
  $nid = arg(1);
  $node = node_load($nid);
  if (!$node->book) {
    $nid = $book_top_page;
  }
}

$node = node_load($nid);
$tree = book_menu_subtree_data($node->book);
// There should only be one element at the top level.
$data = array_shift($tree);
$block['subject'] = theme('book_title_link', $data['link']);
$block['content'] = '<div>';
$block['content'] .= ($data['below']) ? menu_tree_output($data['below']) : '';
$block['content'] .= '</div>';
print $block['content'];
?>
hadavar’s picture

Hi,

I really liked how the snippet you have above creates a block below my text on the page. But, the book outline still shows. So, basically, there are two book outlines on the page, one can show all the child pages, the other doesn't. Would you know how to hide/delete the book outline that doesn't show child pages without altering the book outline?

Here's basically what the page would look like:

- CONTENT
- BOOK OUTLINE (using your snippet) - shows child pages
- BOOK OUTLINE (using the book module) - doesn't show child pages

Thank you for your help!

Frederickweiss’s picture

THIS WORKED PERFECTLY, thank you so much.

mcjim’s picture

http://drupal.org/project/bookblock
It allows you to display any individual book menu as a standard block that you can configure to appear on any pages you choose.

calvintennant’s picture

Any plans to update this for D7?

toddgee’s picture

It's a pretty minor change -- you just just need to pass the output of menu_tree_output() into drupal_render().

So:

  $book_top_page= YOUR_NID;
  $tree = menu_tree_all_data(book_menu_name($book_top_page));
  print drupal_render(menu_tree_output($tree));
rwilson0429’s picture

Thanks toddgee, that worked perfectly.

ReggieW

Andrés Chandía’s picture

First of all, thanks for this snippet, it works great.

I have huge books, so the block becomes huge too, is there a way to show it collapsed?

Thanks again

@ch

Andrés Chandía’s picture

Yet another requirement, is there a way of showing the block only when any of the pages of the corresponding book is being browsed, for instance show block1 for book1 when I browse any of the pages of book1, but when I browse book2, block1 should remain hidden or not vissible.

Thanks

@ch

rofllolomg’s picture

Sorry, new user here, probably the newest one. I'm not familiar with PHP in the slightest. :/

The block code doesn't work for me. There's just the menu title ("Table of Contents", I named it.) but nothing under that. Nothing, nada. Zero.
I pretty much copied the original code along with toddgee's drupal 7 add-on. Didn't really change much, added layer and referenced a book node, but under the Title (which I noticed disappears if I remove an empty line between the original code + the drupal 7 add-on). Did I miss something or could itb e that my drupal is 7.2? Does anyone have any advice for me?

Thank in advance.

kourosh.afsari’s picture

Hi, Is there any possibility to add some sort of checking for menu items to check if the parent menu item has been viewed and, if it has then, display the child items ? otherwise all the childs/submenues will be collapsed/hidden. some sort of staging... Does anyone have any idea how to do that?