<?php
/**
 * This file implements the ChapterCache class.
 *
 * This file is part of the evoCore framework - {@link http://evocore.net/}
 * See also {@link https://github.com/b2evolution/b2evolution}.
 *
 * @license GNU GPL v2 - {@link http://b2evolution.net/about/gnu-gpl-license}
 *
 * @copyright (c)2003-2020 by Francois Planque - {@link http://fplanque.com/}
 *
 * @package evocore
 *
 * @author fplanque: Francois PLANQUE
 */
if( !defined('EVO_MAIN_INIT') ) die( 'Please, do not access this page directly.' );

load_class( 'chapters/model/_chapter.class.php', 'Chapter' );

/**
 * ChapterCache Class
 *
 * @package evocore
 */
class ChapterCache extends DataObjectCache
{
	var $subset_cache = array();

	/**
	 * Which property of the objects defines the subset
	 */
	var $subset_property;

	/**
	 * Which subsets have been loaded
	 */
	var $loaded_subsets = array();

	/**
	 * These are the level 0 categories (which have no parent)
	 */
	var $root_cats = array();

	/**
	 * These are the level 0 categories (which have no parent) for each subset
	 */
	var $subset_root_cats = array();

	/**
	 * Have the children been revealed for all subsets yet?
	 */
	var $revealed_all_children = false;
	/**
	 * Have the children been revealed for all subsets yet?
	 */
	var $revealed_subsets = array();

	/**
	 * Flags about which arrays are already sorted
	 */
	var $sorted_flags = array();

	/**
	 * Lazy filled index of url titles
	 */
	var $urlname_index = array();


	/**
	 * Constructor
	 */
	function __construct()
	{
		parent::__construct( 'Chapter', false, 'T_categories', 'cat_', 'cat_ID', 'cat_name' );

		// This is the property by which we will filter out subsets, for exampel 'blog_ID' if we want to only load a specific collection:
		$this->subset_property = 'blog_ID';
	}


	/**
	 * Empty/reset the cache
	 */
	function clear( $keep_shadow = false )
	{
		$this->subset_cache = array();
		$this->loaded_subsets = array();
		$this->root_cats = array();
		$this->subset_root_cats = array();
		$this->revealed_all_children = false;
		$this->revealed_subsets = array();
		$this->sorted_flags = array();
		$this->urlname_index = array();
		parent::clear( $keep_shadow );
	}


	/**
	 * Add a dataobject to the cache
	 *
	 * @param object Chapter object to add in cache
	 * @return boolean TRUE on adding, FALSE on wrong object or if it is already in cache
	 */
	function add( $Chapter )
	{
		if( parent::add( $Chapter ) )
		{	// Successfully added

			if( !empty( $this->subset_property ) )
			{	// Also add to subset cache:
				$this->subset_cache[ $Chapter->{$this->subset_property} ][ $Chapter->ID ] = $Chapter;
			}
			return true;
		}

		return false;
	}


	/**
	 * Get an object from cache by ID
	 *
	 * Load the cache if necessary (all at once if allowed).
	 *
	 * @param integer ID of object to load
	 * @param boolean true if function should die on error
	 * @param boolean true if function should die on empty/null
 	 * @param integer|NULL NULL for all subsets
	 * @return Chapter Reference on cached object or false.
	 */
	function & get_by_ID( $req_ID, $halt_on_error = true, $halt_on_empty = true, $subset_ID = NULL )
	{
		global $DB, $Debuglog;

		if( empty($req_ID) )
		{
			if($halt_on_empty)
			{
				debug_die( "Requested Chapter from $this->dbtablename without ID!" );
			}
			$r = NULL;
			return $r;
		}

		if( !empty( $this->cache[ $req_ID ] ) )
		{ // Already in cache
			$Debuglog->add( "Accessing Chapter($req_ID) from cache", 'dataobjects' );
			return $this->cache[ $req_ID ];
		}
		elseif( !$this->all_loaded )
		{ // Not in cache, but not everything is loaded yet
			if( $this->load_all || is_null($subset_ID) )
			{ // It's ok to just load everything:
				$this->load_all();
			}
			else
			{ // Load just the requested object:
				$Debuglog->add( "Loading <strong>$this->objtype($req_ID)</strong> into cache", 'dataobjects' );
				// Note: $req_ID MUST be an unsigned integer. This is how DataObject works.
				$sql = "SELECT *
				          FROM T_categories
				         WHERE cat_ID = $req_ID
				           AND cat_blog_ID = ".$subset_ID;

				if( $row = $DB->get_row( $sql, OBJECT, 0, 'ChapterCache::get_by_ID()' ) )
				{
					if( ! $this->instantiate( $row ) )
					{
						$Debuglog->add( 'Could not add() object to cache!', 'dataobjects' );
					}
				}
				else
				{
					$Debuglog->add( 'Could not get DataObject by ID. Query: '.$sql, 'dataobjects' );
				}
			}
		}

		if( empty( $this->cache[ $req_ID ] ) )
		{ // Requested object does not exist
			// $Debuglog->add( 'failure', 'dataobjects' );
			if( $halt_on_error )
			{
				debug_die( "Requested $this->objtype does not exist!" );
			}
			$r = false;
			return $r;
		}

		return $this->cache[ $req_ID ];
	}


	/**
	 * Get an array with chapters ID that are located in the given blog from root to the given chapter
	 *
	 * @param integer Blog ID
	 * @param integer Chapter ID
	 * @return array Chapters ID
	 */
	function get_chapter_path( $blog_ID, $chapter_ID )
	{
		$this->load_subset( $blog_ID );

		$chapter_path = array( $chapter_ID );
		if( isset( $this->subset_cache[ $blog_ID ] ) )
		{
			$chapters = $this->subset_cache[ $blog_ID ];
			if( isset( $chapters[ $chapter_ID ] ) )
			{
				$Chapter = $chapters[ $chapter_ID ];
				while( $Chapter->get( 'parent_ID' ) > 0 )
				{
					$chapter_path[] = $Chapter->get( 'parent_ID' );
					// Select a parent chapter
					$Chapter = $chapters[ $Chapter->get( 'parent_ID' ) ];
				}
			}
		}

		return $chapter_path;
	}


	/**
	 * Get an object from cache by urlname
	 *
	 * Load the cache if necessary (all at once if allowed).
	 *
	 * @param string ID of object to load
	 * @param boolean true if function should die on error
	 * @return reference on cached object
	 */
	function & get_by_urlname( $req_urlname, $halt_on_error = true )
	{
		global $DB, $Debuglog;

		if( !isset( $this->urlname_index[$req_urlname] ) )
		{ // not yet in cache:
			// Load just the requested object:
			$Debuglog->add( "Loading <strong>$this->objtype($req_urlname)</strong> into cache", 'dataobjects' );
			$sql = "SELECT *
			          FROM $this->dbtablename
			         WHERE cat_urlname = ".$DB->quote($req_urlname);
			$row = $DB->get_row( $sql );
			if( empty( $row ) )
			{	// Requested object does not exist
				if( $halt_on_error ) debug_die( "Requested $this->objtype does not exist!" );
				// put into index:
				$this->urlname_index[$req_urlname] = false;

				return $this->urlname_index[$req_urlname];
			}

			$this->instantiate( $row );

			// put into index:
			$this->urlname_index[$req_urlname] = & $this->cache[ $row->cat_ID ];
		}
		else
		{
			$Debuglog->add( "Retrieving <strong>$this->objtype($req_urlname)</strong> from cache" );
		}

		return $this->urlname_index[$req_urlname];
	}


	/**
	 * Load a list of chapter referenced by their urlname into the cache
	 *
	 * @param array of urlnames of Chapters to load
	 */
	function load_urlname_array( $req_array )
	{
		global $DB, $Debuglog;

		$req_list = $DB->quote( $req_array );
		$Debuglog->add( "Loading <strong>$this->objtype($req_list)</strong> into cache", 'dataobjects' );
		$sql = "SELECT * FROM $this->dbtablename WHERE cat_urlname IN ( $req_list )";
		$dbIDname = $this->dbIDname;
		$objtype = $this->objtype;
		foreach( $DB->get_results( $sql ) as $row )
		{
			$this->cache[ $row->$dbIDname ] = new $objtype( $row ); // COPY!

			// put into index:
			$this->urlname_index[$row->cat_urlname] = & $this->cache[ $row->$dbIDname ];

			$Debuglog->add( "Cached <strong>$this->objtype($row->cat_urlname)</strong>" );
		}
	}



	/**
	 * Load a keyed subset of the cache
	 *
 	 * @param integer|NULL NULL for all subsets
 	 * @param string Force 'order by' setting ('manual' = 'ORDER BY cat_order')
	 */
	function load_subset( $subset_ID, $force_order_by = '' )
	{
		global $DB, $Debuglog, $Settings;

		if( $this->all_loaded || isset( $this->loaded_subsets[$subset_ID] ) )
		{ // Already loaded
			return false;
		}

		// fp> TODO: This kills other subsets. BAD if we want to handle multiple subsets independently
		$this->clear( true );

		$Debuglog->add( 'ChapterCache - Loading <strong>chapters('.$subset_ID.')</strong> into cache', 'dataobjects' );
		$sql = 'SELECT *
					FROM T_categories
					WHERE cat_blog_ID = '.$subset_ID;

		foreach( $DB->get_results( $sql, OBJECT, 'Loading chapters('.$subset_ID.') into cache' ) as $row )
		{
			// Instantiate a custom object
			$this->instantiate( $row );
		}

		$this->loaded_subsets[$subset_ID] = true;

		return true;
	}


	/**
	 * Get chapters in the given subset
	 * Note: This is not ordered and it returns all of chpaters and subchapters
	 *
	 * @param integer subset ID
	 * @return array of Chapters
	 */
	function get_chapters_by_subset( $subset_ID )
	{
		if( ! isset( $this->loaded_subsets[$subset_ID] ) )
		{ // Load subset
			$this->load_subset( $subset_ID );
		}

		return $this->subset_cache[$subset_ID];
	}


	/**
	 * Get chapters by collection ID and parent ID
	 *
	 * @param integer collection ID
	 * @param integer parent chapter ID
	 * @param boolean set to true to sort the result, leave it on false otherwise
	 *
	 * @return array set of chapters
	 */
	function get_chapters( $coll_ID, $parent_ID = 0, $sorted = false )
	{
		$this->reveal_children( $coll_ID, $sorted );

		if( $parent_ID == 0 )
		{
			return $this->subset_root_cats[$coll_ID];
		}

		$parent_Chapter = & $this->get_by_ID( $parent_ID );
		if( $sorted )
		{ // Sort chapter
			$parent_Chapter->sort_children();
		}

		return $parent_Chapter->children;
	}


	/**
	 * Check if there are categories in the given subset
	 *
	 * @param integer subset ID
	 * @return boolean true if subset has chapters, false otherwise
	 */
	function has_chapters_in_subset( $subset_ID )
	{
		$this->load_subset( $subset_ID );
		return ! empty( $this->subset_cache[$subset_ID] );
	}


	/**
	 * Move a chapter and its descendants to a different collection
	 *
	 * @param integer
	 * @param integer
	 * @param integer
	 */
	function move_Chapter_subtree( $chapter_ID, $src_collection_ID, $dest_collection_ID )
	{
		/**
		 * @var DB
		 */
		global $DB;

		// Make sure children have been revealed for specific subset:
		$this->reveal_children( $src_collection_ID );

		// fp>We get the Chapter AFTER reveal_children, because something is wrong with reveal_children or get_by_ID
		// I don't know what right now, but if we get Chapter earlier, we'll be stuck with an old copy of it that does NOT have the children
		// TODO: find out what's freakin wrong
		$Chapter = $this->get_by_ID($chapter_ID);

		$chapters_to_move = array();
		// Get $chapters_to_move:
		$this->recurse_move_subtree( $Chapter, $chapters_to_move );
		// pre_dump( $chapters_to_move );

		$DB->begin();

		// Move to root:
		if( $parent_Chapter = $Chapter->get_parent_Chapter() )
		{	// Was not already at root, cut it and move it:
			// echo 'Move to root';
			$Chapter->set( 'parent_ID', NULL );
			$Chapter->dbupdate();
		}

		// Move Chapters to new Blog:
		$sql = 'UPDATE T_categories
							 SET cat_blog_ID = '.$dest_collection_ID.'
						 WHERE cat_blog_ID = '.$src_collection_ID /* extra security */ .'
						 	 AND cat_ID IN ('.implode( ',', $chapters_to_move ).')';
		$DB->query( $sql );

		$DB->commit();

		// Now the cache is badly screwed. Reseting it is fair enough, because this won't happen very often.
		$this->clear();
	}


	/**
	 * Support function for move_Chapter_subtree
	 *
	 * @param Chapter
	 * @param array
	 */
	function recurse_move_subtree( & $Chapter, & $list_array )
	{
		// Add this to the list:
		$list_array[] = $Chapter->ID;

		foreach( $Chapter->children as $child_Chapter )
		{
			$this->recurse_move_subtree( $child_Chapter, $list_array );
		}
	}


	/**
	 * Instanciate a new object within this cache
 	 *
 	 * @param object|NULL
 	 * @param integer|NULL subset to use for new object
	 */
	function & new_obj( $row = NULL, $subset_ID = NULL )
	{
		// Instantiate a custom object
		$Chapter = new Chapter( $row, $subset_ID ); // Copy

		return $Chapter;
	}


	/**
	 * Reveal children
	 *
	 * After executing this, each Chapter will have an array pointing to its direct children
	 *
	 * @param integer|NULL NULL for all subsets
	 * @param boolean Set true to sort root categories or leave it on false otherwise
	 */
	function reveal_children( $subset_ID = NULL, $sort_root_cats = false )
	{
		global $Debuglog;
		global $Timer;

		if( $this->revealed_all_children )
		{	// All children have already been revealed: (can happen even if we require a subset *now*)
			if( $sort_root_cats )
			{
				$this->sort_root_cats( $subset_ID );
			}
			return;
		}

		if( empty($this->subset_property) )
		{	// We are not handling a subset but everything instead:

			echo 'DEBUG - PARTIAL IMPLEMENTATION - Revealing all children -- this is not yet handling all edge cases that the subset version can handle';

			// Make sure everything has been loaded:
			$this->load_all();

			// Reveal children:
			if( !empty( $this->cache ) )
			{	// There are loaded categories, so loop on all loaded categories to set their children list if it has:
				foreach( $this->cache as $cat_ID => $Chapter )
				{
					// echo $Chapter->name;
					if( ! is_null( $Chapter->parent_ID ) )
					{	// This category has a parent, so add it to its parent children list:
						$this->cache[$Chapter->parent_ID]->add_child_category( $this->cache[$cat_ID] );
					}
					else
					{	// This category has no parent, so add it to the parent categories list
						$this->root_cats[] = & $this->cache[$cat_ID];
					}
				}
			}

			if( $sort_root_cats )
			{ // Sort root categories as it was requested
				$this->sort_root_cats( NULL );
			}

			$this->revealed_all_children = true;
		}
		else
		{	// We are handling a subset (for example Chapers of a specific blog_ID only):

			if( is_null( $subset_ID ) )
			{	// No specific subset requested, we are going to reveal all subsets in a row:

				// Make sure everything has been loaded:
				$this->load_all();

				// Commented out, because it is needed in case of cross posting through blogs
				// echo 'DEBUG - PARTIAL IMPLEMENTATION - REVEALING ALL SUBSETS in a row. Is this needed?';

				foreach( $this->subset_cache as $subset_ID => $dummy )
				{
					$this->reveal_children( $subset_ID, $sort_root_cats );
				}

				$this->revealed_all_children = true;
			}
			else
			{	// We're interested in a specific subset:
				// *** THIS IS THE CASE USED IN B2EVOLUTION ***

				if( !empty( $this->revealed_subsets[$subset_ID] ) )
				{	// Children have already been revealed:
					if( $sort_root_cats )
					{
						$this->sort_root_cats( $subset_ID );
					}
					return;
				}

				$Timer->resume('reveal_children', false );

				$Debuglog->add( 'Revealing subset of children', 'CategoryCache' );

				// Make sure the requested subset has been loaded:
				$this->load_subset($subset_ID);

				// Reveal children:
				if( !empty( $this->subset_cache[$subset_ID] ) )
				{	// There are loaded categories

					// Now check that each category has a path to the root:
					foreach( $this->subset_cache[$subset_ID] as $cat_ID => $dummy )	// "as" would give a freakin COPY of the object :(((
					{
						$this->check_path_to_root( $subset_ID, $cat_ID );
					}

					// loop on all loaded categories to set their children list if they have some:
					foreach( $this->subset_cache[$subset_ID] as $cat_ID => $dummy )	// "as" would give a freakin COPY of the object :(((
					{
						$Chapter = & $this->subset_cache[$subset_ID][$cat_ID];
						// echo '<br>'.$cat_ID;
						// echo $Chapter->name;
						if( ! is_null( $Chapter->parent_ID ) )
						{	// This category has a parent, so add it to its parent children list:
							$Debuglog->add( 'adding child with ID='.$cat_ID.' to parent ID='.$Chapter->parent_ID, 'CategoryCache' );
							$this->cache[$Chapter->parent_ID]->add_child_category( $this->cache[$cat_ID] );
						}
						else
						{	// This category has no parent, so add it to the parent categories list
							$Debuglog->add( 'adding parent with ID='.$cat_ID, 'CategoryCache' );
							$this->root_cats[] = & $Chapter; // $this->cache[$cat_ID];
							$this->subset_root_cats[$this->cache[$cat_ID]->{$this->subset_property}][] = & $Chapter; // $this->cache[$cat_ID];
						}
					}

					if( $sort_root_cats )
					{ // Sort subset root categories as it was requested
						$this->sort_root_cats( $subset_ID );
					}

					$Timer->pause('reveal_children', false );

				}

				// Children have been revealed.
				$this->revealed_subsets[$subset_ID] = true;
			}
		}

	}


	/**
	 * Support function for reveal_children()
	 *
	 * @param integer
	 * @param integer
	 * @return true if root parent
	 */
	function check_path_to_root( $subset_ID, $cat_ID, $path_array = array() )
	{
		global $Debuglog;
		$padding = str_pad('',count($path_array),'*');
		$Debuglog->add( $padding.'Checking path to root for cat ID='.$cat_ID.' with path:'.implode(',',$path_array), 'CategoryCache' );
		$Chapter = & $this->subset_cache[$subset_ID][$cat_ID];
		if( is_null( $Chapter->parent_ID ) )
		{	// Found root parent
			$Debuglog->add( $padding.'*OK', 'CategoryCache' );
			return true;
		}
		// This is not the last parent...
		// Record path...
		$path_array[] = $Chapter->ID;

		if( in_array( $Chapter->parent_ID, $path_array ) )
		{	// We are about to enter an infinite loop
			$Debuglog->add( $padding.'*LOOP! -> assign to root', 'CategoryCache' );
			$Chapter->parent_ID = NULL;
			return false;
		}
		elseif( ! isset($this->cache[$Chapter->parent_ID] ) )
		{
			$Debuglog->add( $padding.'*Missing parent ID('.$Chapter->parent_ID.')! -> assign to root', 'CategoryCache' );
			$Chapter->parent_ID = NULL;
			return false;
		}

		// Recurse!
		return $this->check_path_to_root( $subset_ID, $Chapter->parent_ID, $path_array );
	}


	/**
	 * Compare an Item with a Chapter based on the given order type
	 * TODO: asimo> Move this function to a different class ( E.g. to Chapter or to ChapterCache )
	 *
	 * @param Object Item
	 * @param Object Chapter
	 * @param string order type: 'alpha' or 'manual'
	 * @return @return number -1 if Item < Chapter, 1 if Item > Chapter, 0 if Item == Chapter
	 */
	function compare_item_with_chapter( $Item, $Chapter, $order_type )
	{
		if( $order_type == 'alpha' )
		{
			return strcmp( $Item->title, $Chapter->name );
		}

		// Get item order depending on category:
		$item_order = $Item->get_order( $Chapter->get( 'parent_ID' ) );

		if( $item_order == NULL )
		{
			return $Chapter->order == NULL ? 0 : 1;
		}
		elseif( $Chapter->order == NULL )
		{
			return -1;
		}

		return ( $item_order < $Chapter->order ) ? -1 : ( ( $item_order > $Chapter->order ) ? 1 : 0 );
	}


	/**
	 * Sort root categories or root categories in a subset if they are not sorted yet
	 *
	 * @param integer subset_ID, leave it on null to sort in all subsets
	 */
	function sort_root_cats( $subset_ID = NULL )
	{
		if( $subset_ID == NULL && empty($this->subset_property) )
		{
			if( !$this->sorted_flags['root_cats'] )
			{ // Root cats were not sorted yet
				usort( $this->root_cats, array( 'Chapter','compare_chapters' ) );
				$this->sorted_flags['root_cats'] = true;
			}
			return;
		}

		if( $subset_ID == NULL )
		{
			foreach( array_keys( $this->subset_root_cats ) as $key )
			{
				if( $this->sorted_flags[$key] )
				{ // This subset was already sorted
					continue;
				}

				usort( $this->subset_root_cats[$key], array( 'Chapter','compare_chapters' ) );
				$this->sorted_flags[$key] = true;
			}
			return;
		}

		if( empty($this->sorted_flags[$subset_ID]) )
		{ // This subset was not sorted yet
			usort( $this->subset_root_cats[$subset_ID], array( 'Chapter','compare_chapters' ) );
			$this->sorted_flags[$subset_ID] = true;
		}
	}


	/**
	 * Return recursive display of loaded categories
	 *
	 * @param array callback functions (to format the display)
	 * @param integer|NULL NULL for all subsets
	 * @param array categories list to display
	 * @param integer depth of categories list
	 * @param integer Max depth of categories list, 0/empty - is infinite
	 * @param array Other params regarding the recursive display:
	 *   boolean sorted, sort categories or not - default value is false
	 *   string chapter_path, array of chapter ids from root category to the selected category
	 *   boolean expand_all, set true to expand all categories and not only the one in the chapter path
	 *   etc.
	 *
	 * @return string recursive list of all loaded categories
	 */
	function recurse( $callbacks, $subset_ID = NULL, $cat_array = NULL, $level = 0, $max_level = 0, $params = array() )
	{
		$params = array_merge( array(
				'highlight_current' => true,
				'sorted' => false,
				'chapter_path' => array(),
				'expand_all' => true,
				'posts_are_loaded' => false,
				'subset_ID' => $subset_ID,
				'max_level' => $max_level
			), $params );
		$sorted = $params['sorted'];
		$display_posts = ! empty( $callbacks['posts'] );

		// Make sure children have been (loaded and) revealed for specific subset:
		$this->reveal_children( $subset_ID, $sorted );

		if( $display_posts && !$params['posts_are_loaded'] && ( $params['expand_all'] || !empty( $params['chapter_path'] ) ) )
		{ // Posts needs to be loaded
			$ItemCache = & get_ItemCache();
			$ItemCache->load_by_categories( $params['chapter_path'], $subset_ID );
			$params['posts_are_loaded'] = true;
		}

		if( is_null( $cat_array ) )
		{	// Get all parent categories:
			if( is_null( $subset_ID ) )
			{
				$cat_array = $this->root_cats;
			}
			elseif( isset( $this->subset_root_cats[$subset_ID] ) )
			{ // We have root cats for the requested subset:
				$cat_array = $this->subset_root_cats[$subset_ID];
			}
			else
			{
				$cat_array = array();
			}
		}

		$r = '';

		// Go through all categories at this level:
		foreach( $cat_array as $cat )
		{
			if( isset($params['excluded_cat_IDs']) && in_array(  $cat->ID, $params['excluded_cat_IDs'] ) )
			{	// Category is excluded, Skip it:
				continue;
			}
			
			// Check if category is expended
			$params['is_selected'] = $params['highlight_current'] && in_array( $cat->ID, $params['chapter_path'] );
			$params['is_opened'] = $params['expand_all'] || $params['is_selected'];

			// Display a category:
			if( is_array( $callbacks['line'] ) )
			{ // object callback:
				$r .= $callbacks['line'][0]->{$callbacks['line'][1]}( $cat, $level, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
			}
			else
			{
				$r .= $callbacks['line']( $cat, $level, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
			}

			if( $params['is_opened'] || ( $max_level > $level + 1 ) )
			{	// Iterate through sub categories recursively:
				$params['level'] = $level;
				$r .= $this->iterate_through_category_children( $cat, $callbacks, true, $params );
			}
		}

		if( ! empty( $r ) )
		{ // We have something to display at this level, wrap in in before/after:
			$r_before = '';
			if( is_array( $callbacks['before_level'] ) )
			{ // object callback:
				$r_before .= $callbacks['before_level'][0]->{$callbacks['before_level'][1]}( $level ); // <ul>
			}
			elseif( isset( $callbacks['before_level'] ) )
			{
				$r_before .= $callbacks['before_level']( $level ); // <ul>
			}

			$r = $r_before.$r;

			if( is_array( $callbacks['after_level'] ) )
			{ // object callback:
				$r .= $callbacks['after_level'][0]->{$callbacks['after_level'][1]}( $level ); // </ul>
			}
			elseif( isset( $callbacks['after_level'] ) )
			{
				$r .= $callbacks['after_level']( $level ); // </ul>
			}
		}

		return $r;
	}


	/**
	 * Iterate through the given Chapter sub cats and items
	 *
	 * @param Object Chapter
	 * @param array Callback functions to display a Chapter and to display an Item
	 * @param boolean Set true to iterate sub categories recursively, false otherwise
	 * @param array Any additional params ( e.g. 'sorted', 'level', 'list_subs_start', etc. )
	 * @return string the concatenated callbacks result
	 */
	function iterate_through_category_children( $Chapter, $callbacks, $recurse = false, $params = array() )
	{
		$r = "";
		$cat_items = array();
		$has_sub_cats = ! empty( $Chapter->children );
		$params = array_merge( array(
				'sorted'    => false,
				'level'     => 0,
				'max_level' => 0,
				'subset_ID' => $Chapter->blog_ID,
				'items_order_alpha_func' => 'compare_items_by_title',
				'exclude_cats' => array(),
			), $params );

		if( $params['sorted'] && $has_sub_cats )
		{
			$Chapter->sort_children();
		}
		if( ! empty( $callbacks['posts'] ) )
		{
			$ItemCache = & get_ItemCache();
			$cat_items = $ItemCache->get_by_cat_ID( $Chapter->ID, $params['items_order_alpha_func'] );
		}

		// Does this category have content to display?
		if( $has_sub_cats || !empty( $cat_items ) )
		{ // Display category or posts
			$cat_index = 0;
			$item_index = 0;
			$subcats_to_display = array();
			$chapter_children_ids = array_keys( $Chapter->children );
			$has_more_children = isset( $chapter_children_ids[$cat_index] );
			$has_more_items = isset( $cat_items[$item_index] );
			// alpha or manual order? 
			$cat_order = $Chapter->get_subcat_ordering();
			// Set post params for post display
			$params['chapter_ID'] = $Chapter->ID;
			$params['cat_order'] = $cat_order;

			if( ( $has_more_children || $has_more_items ) && isset( $params['list_subs_start'] ) )
			{
				$r .= $params['list_subs_start'];
			}

			// Display all sub Chapters and items of current Chapter:
			while( $has_more_children || $has_more_items )
			{
				$current_sub_Chapter = $has_more_children ? $Chapter->children[$chapter_children_ids[$cat_index]] : NULL;
				$current_Item = $has_more_items ? $cat_items[$item_index] : NULL;

				// Determine if the next thing to display is a Chapter or an Item:
				if( $current_Item != NULL 
					&& ( $current_sub_Chapter == NULL 
						|| ( $this->compare_item_with_chapter( $current_Item, $current_sub_Chapter, $cat_order ) <= 0 ) ) )
				{	// Next Item should display before Next Chapter/subcat

					if( ! empty( $subcats_to_display ) )
					{
						if( $recurse )
						{	// Display sub-contents:
							$r .= $this->recurse( $callbacks, $params['subset_ID'], $subcats_to_display, $params['level'] + 1, $params['max_level'], $params );
						}
						else
						{	// Display each category without recursion:
							foreach( $subcats_to_display as $sub_Chapter )
							{ // Display each category:
								if( in_array( $sub_Chapter->ID, $params['exclude_cats'] ) )
								{	// Exclude category:
									continue;
								}
								if( is_array( $callbacks['line'] ) )
								{ // object callback:
									$r .= $callbacks['line'][0]->{$callbacks['line'][1]}( $sub_Chapter, 0, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
								}
								else
								{
									$r .= $callbacks['line']( $sub_Chapter, 0, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
								}
							}
						}
						$subcats_to_display = array();
					}

					// Display an Item:
					if( is_array( $callbacks['posts'] ) )
					{ // object callback:
						$r .= $callbacks['posts'][0]->{$callbacks['posts'][1]}( $current_Item, $params['level'] + 1, $params );
					}
					else
					{
						$r .= $callbacks['posts']( $current_Item, $params['level'] + 1, $params );
					}
					$has_more_items = isset( $cat_items[++$item_index] );
				}
				elseif( $current_sub_Chapter != NULL )
				{ 	// Next Chapter should display before next Item:

					$subcats_to_display[] = $current_sub_Chapter;
					$has_more_children = isset( $chapter_children_ids[++$cat_index] );
				}
			}

			if( ! empty( $subcats_to_display ) )
			{ // Display all subcats which were not displayed yet:
				if( $recurse )
				{
					$r .= $this->recurse( $callbacks, $params['subset_ID'], $subcats_to_display, $params['level'] + 1, $params['max_level'], $params );
				}
				else
				{ // Display each category without recursion:
					foreach( $subcats_to_display as $sub_Chapter )
					{ // Display each category:
						if( in_array( $sub_Chapter->ID, $params['exclude_cats'] ) )
						{	// Exclude category:
							continue;
						}
						if( is_array( $callbacks['line'] ) )
						{ // object callback:
							$r .= $callbacks['line'][0]->{$callbacks['line'][1]}( $sub_Chapter, 0, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
						}
						else
						{
							$r .= $callbacks['line']( $sub_Chapter, 0, $params ); // <li> Category  - or - <tr><td>Category</td></tr> ...
						}
					}
				}
			}

			if( ( $cat_index > 0 || $item_index > 0 ) && isset( $params['list_subs_end'] ) )
			{
				$r .= $params['list_subs_end'];
			}
		}
		elseif( isset( $callbacks['no_children'] ) )
		{ // Display message when no children:
			
			if( is_array( $callbacks['no_children'] ) )
			{ // object callback:
				$r .= $callbacks['no_children'][0]->{$callbacks['no_children'][1]}( $Chapter, $params['level'] + 1 ); // </li>
			} else {
				$r .= $callbacks['no_children']( $Chapter, $params['level'] + 1 ); // </li>
			}
		}

		return $r;
	}


	/**
	 * Return array of recursive select options list of all loaded categories
	 *
	 * @param integer NULL for all subsets
	 * @param boolean Include the root element?
	 * @param array GenercCategory objects to display (will recurse from those starting points)
	 * @param integer depth of categories list
	 * @param array IDs of categories to exclude (their children will be ignored to)
	 *
	 * @return array select options list of all loaded categories
	 */
	function recurse_select_options( $subset_ID = NULL, $include_root = false, $Cat_array = NULL,
							$level = 0, $exclude_array = array() )
	{
		// Make sure children have been revealed for specific subset:
		$this->reveal_children( $subset_ID );

		if( is_null( $Cat_array ) )
		{	// Get all parent categorie:
			$Cat_array = $this->root_cats;
		}

		// Sort categories alphabetically or manually depending on settings:
		usort( $Cat_array, array( 'Chapter', 'compare_chapters' ) );

		$options = array();

		if( $include_root )
		{
			$options[] = T_('Root');
			$level++;
		}

		foreach( $Cat_array as $Chapter )
		{
			if( in_array( $Chapter->ID, $exclude_array ) )
			{	// We want to exclude that cat.
				continue;
			}

			// Set category indentation in the select:
			$indent = '';
			for($i = 0; $i < $level; $i++)
			{
				$indent .='&nbsp;&nbsp;';
			}
			// Set category option:
			$options[ $Chapter->ID ] = $indent.$Chapter->name;

			if( ! empty( $Chapter->children ) )
			{	// Add children categories:
				$options += $this->recurse_select_options( $subset_ID, false, $Chapter->children, $level+1, $exclude_array );
			}
		}

		return $options;
	}


	/**
	 * Return string of recursive select options list of all loaded categories
	 *
	 * @param integer selected category in the select input
	 * @param integer NULL for all subsets
	 * @param boolean Include the root element?
	 * @param array GenercCategory objects to display (will recurse from those starting points)
	 * @param integer depth of categories list
	 * @param array IDs of categories to exclude (their children will be ignored to)
	 *
	 * @return string select options list of all loaded categories
	 */
	function recurse_select( $selected = NULL, $subset_ID = NULL, $include_root = false, $Cat_array = NULL,
							$level = 0, $exclude_array = array() )
	{
		$r = '';

		$options = $this->recurse_select_options( $subset_ID, $include_root, $Cat_array, $level, $exclude_array );

		foreach( $options as $chapter_ID => $option_title )
		{
			$r .= '<option value="'.( $chapter_ID ? $chapter_ID : '' ).'"'.( $chapter_ID == $selected ? ' selected="selected"' : '' ).'>'.$option_title.'</option>';
		}

		return $r;
	}
}

?>