Book Module: How to make a book navigation block appear on all pages.

Last modified: December 29, 2008 - 22:00

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:

<?php
$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 = '<ul class="menu">';
      foreach (
$children[$nid] as $key => $node) {
        if (
$tree = book_struct_recurse($node->nid, $levels_deep - 1, $children, $current_lineage, is_numeric($emulate_book_block) ? ($emulate_book_block - 1 ? $emulate_book_block - 1 : false) : $emulate_book_block )) {
         
// a submenu will be printed below this item
         
$struct .= '<li class="expanded">';
         
$struct .= l($node->title, 'node/'. $node->nid);
         
$struct .= $tree;
         
$struct .= '</li>';
        }
        else {
         
// no submenu will be printed below this item, but let's see if we
          // should call this <li> "collapsed" or just a "leaf"
          // yes, it's complex logic
         
$class = ($emulate_book_block === true || (($emulate_book_block - 1) && $emulate_book_block > $levels_deep)) && $children[$node->nid] ? 'collapsed' : 'leaf';
         
$struct .= '<li class="' . $class . '">' . l($node->title, 'node/'. $node->nid) . '</li>';
        }
      }
     
$struct .= '</ul>';
      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):

<?php
$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:

<?php
if ($node = menu_get_object('node', 1, 'node/'.$book_top_page)) {
 
// Only display this block when $book_top_page refers to an actual book page
 
$title = db_result(db_query(db_rewrite_sql('SELECT n.title FROM {node} n WHERE n.nid = %d'), $node->book['bid']));
 
// Only show the block if the user has view access for the top-level node.
 
if ($title) {
   
// first retrieve the book structure from the menu system
   
$result = db_query('SELECT link_path, link_title AS title, mlid, plid FROM {menu_links} WHERE menu_name="%s" ORDER BY weight, link_title', $node->book['menu_name']);
   
$menu = array();
    while (
$m = db_fetch_object($result)) {
     
$menu[$m->mlid] = $m;
     
$menu[$m->mlid]->nid = $nid = (int)substr($m->link_path, 5);
    }
   
// now use the $menu array to build a proper $children and $parent array
   
$children = array();
   
$parent = array();
    foreach (
$menu as $key => $val) {
     
$nid = is_null($menu[$val->plid]->nid) ? 0 : $menu[$val->plid]->nid;
      if (!
$children[$nid]) {
       
$children[$nid] = array();
      }
     
array_push($children[$nid], $val);
     
$parent[is_null($val->nid) ? 0 : (int)$val->nid] = $nid;
    }
   
// $children array properly formed, now get the $current_lineage array
   
$current_lineage = array();
    if (
arg(0) == 'node' && is_numeric(arg(1)) && $parent[arg(1)]) {
     
$nid = arg(1);
      while (
$nid) {
       
array_unshift($current_lineage, $nid);
       
$nid = $parent[$nid];
      }
    }
    echo
book_struct_recurse($book_top_page, $levels_deep, $children, $current_lineage, $emulate_book_block);
  }
}
?>

Is this right for adding the block to all pages?

mnoyes - May 30, 2007 - 04:00

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?

<?php
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!)

Efficiency in SQL query

jcolbyk - January 13, 2008 - 19:11

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...

Query change

btopro - April 8, 2008 - 18:45

$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

I agree with the potential

coreyp_1 - July 24, 2008 - 21:26

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

Display problem also in Marinelli

mapiedra - April 9, 2008 - 03:38

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).

If you don't care about recursion...

webchick - May 15, 2008 - 14:41

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:

<?php
print book_tree(123);
?>

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

custom block for Drupal 6

mimamim - January 16, 2009 - 17:06

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.

<?php
$book_top_page
=YOURDATA;
$tree = menu_tree_all_data(book_menu_name($book_top_page));
print
menu_tree_output($tree);
?>

How to add a link to add child from menu

tom_mm - January 17, 2009 - 02:37

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?

Difference Between Snippet and API Function

mrken - March 3, 2009 - 21:08

New Drupal user here:

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

<?php
   
echo menu_tree( book_menu_name( $book_top_page ) );
?>

Is it more or less efficient?

Another approach

kassissieh - April 6, 2009 - 23:54

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.

<?php
/**
* 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;
  }
}
?>

unpublished nodes + private module

dpw - April 7, 2009 - 21:46

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;
      }
    }
?>

 
 

Drupal is a registered trademark of Dries Buytaert.