<?php /*

 Composr
 Copyright (c) ocProducts, 2004-2016

 See text/EN/licence.txt for full licencing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  ocProducts Ltd
 * @package    core
 */

/*
Notes:
 - We cannot/do-not fully recurse the Sitemap with arbitrary permissions or as an arbitrary user.
   We do collect permission data, but this is collected for the Permissions Tree Editor, not for node availability meta processing.
   Node availability (of view access) is checked automatically as a part of the sitemap crawl.
   This isn't for no good reason - the Sitemap is intrinsicly variable on a user-to-user basis, it is not necessarily shared.
 - When get_node is called, it is assumed that the node object really can handle the requested page-link.
   If it cannot, it is allowed to crash out in any way.
   This is why you should know what you are calling, or check with handles_page_link.
 - Any node called directly will not respect the content-type/validation requirements.
 - The system is designed to be able to recurse the whole structure without using a lot of memory. This is what the callbacks are for.
   If no callback is used, you should probably set a recurse depth limit.
 - Each recursion level should be operable independently, so that we can re-enter across separate AJAX requests.

Node structure includes the following special data for menu rendering:
 - modifiers (special modifier codes that indicate things: "~"->new_window, "+"->expanded, ("-"->nothing), "?"->check permissions)
 - only_to_page[string] (used to make links only show context-sensitively)
*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__sitemap()
{
    if (php_function_allowed('set_time_limit')) {
        @set_time_limit(100);
    }

    if (!defined('SITEMAP_GATHER_DESCRIPTION')) {
        // Defining what should be gathered with the Sitemap
        define('SITEMAP_GATHER_DESCRIPTION', 1);
        define('SITEMAP_GATHER_IMAGE', 2);
        define('SITEMAP_GATHER_TIMES', 4);
        define('SITEMAP_GATHER_SUBMITTER', 8);
        define('SITEMAP_GATHER_AUTHOR', 16);
        define('SITEMAP_GATHER_VIEWS', 32);
        define('SITEMAP_GATHER_RATING', 64);
        define('SITEMAP_GATHER_NUM_COMMENTS', 128);
        define('SITEMAP_GATHER_META', 256);
        define('SITEMAP_GATHER_CATEGORIES', 512);
        define('SITEMAP_GATHER_VALIDATED', 1024);
        define('SITEMAP_GATHER_DB_ROW', 2048);
        define('SITEMAP_GATHER__ALL', 0x7FFFFFF);

        // Defining how a node will be handle
        define('SITEMAP_NODE_NOT_HANDLED', 0);
        define('SITEMAP_NODE_HANDLED', 1);
        define('SITEMAP_NODE_HANDLED_VIRTUALLY', 2); // Not a real node, but a virtual node for which we can accumulate real nodes at

        // Sitemap importances
        define('SITEMAP_IMPORTANCE_NONE', 0.0);
        //define('SITEMAP_IMPORTANCE_', 0.1);
        define('SITEMAP_IMPORTANCE_LOW', 0.2);
        //define('SITEMAP_IMPORTANCE_', 0.3);
        //define('SITEMAP_IMPORTANCE_', 0.4);
        define('SITEMAP_IMPORTANCE_MEDIUM', 0.5);
        //define('SITEMAP_IMPORTANCE_', 0.6);
        //define('SITEMAP_IMPORTANCE_', 0.7);
        define('SITEMAP_IMPORTANCE_HIGH', 0.8);
        //define('SITEMAP_IMPORTANCE_', 0.9);
        define('SITEMAP_IMPORTANCE_ULTRA', 1.0);

        // Sitemap generation settings
        define('SITEMAP_GEN_NONE', 0);
        define('SITEMAP_GEN_REQUIRE_PERMISSION_SUPPORT', 1); // Only go so deep as needed to find nodes with permission-support (typically, stopping prior to the entry-level).
        define('SITEMAP_GEN_USE_PAGE_GROUPINGS', 2); // Whether to make use of page groupings, to organise stuff with the hook schema, supplementing the default zone organisation.
        define('SITEMAP_GEN_CONSIDER_SECONDARY_CATEGORIES', 4); // Whether to consider secondary categorisations for content that primarily exists elsewhere.
        define('SITEMAP_GEN_CONSIDER_VALIDATION', 8); // Whether to filter out non-validated content.
        define('SITEMAP_GEN_LABEL_CONTENT_TYPES', 16); // Whether to change title labels to show what nodes and what kinds of content (i.e. more technical).
        define('SITEMAP_GEN_NO_EMPTY_PAGE_LINKS', 32); // When iteratively expanding we need to make sure this is set, otherwise we won't be able to expand everything. But when generating menus we do not want it set.
        define('SITEMAP_GEN_KEEP_FULL_STRUCTURE', 64); // Avoid merging structure together to avoid page-link duplication.
        define('SITEMAP_GEN_COLLAPSE_ZONES', 128); // Simulate zone collapse in the Sitemap.
        define('SITEMAP_GEN_CHECK_PERMS', 256); // Check permissions when building up nodes.
        define('SITEMAP_GEN_USE_PAGE_GROUPINGS_SUPPRESS', 512); // Unset SITEMAP_GEN_USE_PAGE_GROUPINGS for one recursion only.

        // Defining how the content-selection list should be put together
        define('CSL_PERMISSION_VIEW', 0);
        define('CSL_PERMISSION_ADD', 1);
        define('CSL_PERMISSION_EDIT', 2);
        define('CSL_PERMISSION_DELETE', 4);

        // Other constants
        define('SITEMAP_MAX_ROWS_PER_LOOP', 500);
    }

    global $IS_SITEMAP_STRUCTURE_LOOPING;
    $IS_SITEMAP_STRUCTURE_LOOPING = array();

    global $HAS_MANY_MATCH_KEYS, $MATCH_KEYS_CACHED;
    $HAS_MANY_MATCH_KEYS = null;
    $MATCH_KEYS_CACHED = null;
}

/**
 * Find details of a position in the Sitemap (shortcut into the object structure).
 *
 * @param  ID_TEXT $page_link The page-link we are finding (blank: root).
 * @param  ?mixed $callback Callback function to send discovered page-links to (null: return).
 * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
 * @param  ?integer $child_cutoff Maximum number of children before we cut off all children (null: no limit).
 * @param  ?integer $max_recurse_depth How deep to go from the Sitemap root (null: no limit).
 * @param  integer $options A bitmask of SITEMAP_GEN_* options.
 * @param  ID_TEXT $zone The zone we will consider ourselves to be operating in (needed due to transparent redirects feature)
 * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
 * @return ?array Node structure (null: working via callback / error).
 */
function retrieve_sitemap_node($page_link = '', $callback = null, $valid_node_types = null, $child_cutoff = null, $max_recurse_depth = null, $options = 0, $zone = '_SEARCH', $meta_gather = 0)
{
    $GLOBALS['NO_QUERY_LIMIT'] = true;

    cms_profile_start_for('retrieve_sitemap_node');

    global $IS_SITEMAP_STRUCTURE_LOOPING;
    $IS_SITEMAP_STRUCTURE_LOOPING = array();

    $test = find_sitemap_object($page_link);
    if (is_null($test)) {
        return null;
    }
    list($ob, $is_virtual) = $test;

    $disable_sitemap = get_value('disable_sitemap');
    if ($disable_sitemap === '2') {
        $valid_node_types = array('page_grouping', 'page', 'entry_point');
    }
    if ($disable_sitemap === '1') {
        return null;
    }

    if ($is_virtual) {
        $children = $ob->get_virtual_nodes($page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, 0, $options, $zone, $meta_gather);
        if (is_null($children)) {
            $children = array();
        }
        return array('children' => $children);
    }
    $ret = $ob->get_node($page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, 1, $options, $zone, $meta_gather);

    cms_profile_end_for('retrieve_sitemap_node', $page_link);

    return $ret;
}

/**
 * Find the Sitemap object that serves a particular page-link.
 *
 * @param  ID_TEXT $page_link The page-link we are finding a Sitemap object for (blank: root).
 * @return ?array A pair: the Sitemap object, and whether you need to make a virtual call (null: cannot find one).
 */
function find_sitemap_object($page_link)
{
    if ($page_link == '') {
        $hook = 'root';
        require_code('hooks/systems/sitemap/root');
        $ob = object_factory('Hook_sitemap_root');

        $is_virtual = false;
    } else {
        $page_link = preg_replace('#:keep_\w+=[^:]*#', '', $page_link);

        $hook = mixed();
        $matches = array();
        $hooks = find_all_hooks('systems', 'sitemap');
        foreach (array_keys($hooks) as $_hook) {
            require_code('hooks/systems/sitemap/' . $_hook);
            $ob = object_factory('Hook_sitemap_' . $_hook);
            if ($ob->is_active()) {
                $is_handled = $ob->handles_page_link($page_link);
                if ($is_handled != SITEMAP_NODE_NOT_HANDLED) {
                    $sup = $_hook;
                    if ($_hook == 'entry_point') {
                        $sup = 'aaa'/*high priority (we don't want content category hooks to capture it)*/;
                    }
                    if ($_hook == 'page') {
                        $sup = 'zzz'/*low priority*/;
                    }
                    $order_key = '_' . strval($is_handled) . '_' . $sup;
                    $matches[$order_key] = $_hook;
                }
            }
        }
        if (count($matches) != 0) {
            ksort($matches);
            $hook = reset($matches);
            $ob = object_factory('Hook_sitemap_' . $hook);

            $is_handled = intval(substr(key($matches), 1, 1));
            $is_virtual = ($is_handled == SITEMAP_NODE_HANDLED_VIRTUALLY);
        }
        if (is_null($hook)) {
            attach_message(do_lang_tempcode('_MISSING_RESOURCE', $page_link), 'warn');
            return null;
        }
    }

    return array($ob, $is_virtual);
}

/**
 * Sitemap node type base class.
 *
 * @package        core
 */
abstract class Hook_sitemap_base
{
    /**
     * Take the specified parameters, and try to find the corresponding page.
     *
     * @param  ID_TEXT $page The codename of the page to load
     * @param  ID_TEXT $zone The zone the page is being loaded in
     * @param  ?ID_TEXT $page_type The type of page - for if you know it (null: don't know it)
     * @return ~array A list of details (false: page not found)
     */
    protected function _request_page_details($page, $zone, $page_type = null)
    {
        require_code('site');
        $details = _request_page($page, $zone, $page_type);
        if ($details !== false) {
            if ($details[0] == 'REDIRECT') {
                if ($details[1]['r_is_transparent'] == 0) {
                    return false;
                }

                $details = _request_page($details[1]['r_to_page'], $details[1]['r_to_zone'], $page_type, null, true);
            }
        }
        return $details;
    }

    /**
     * Find whether a page should be omitted from the sitemap.
     *
     * @param  ID_TEXT $zone The zone the page is being loaded in
     * @param  ID_TEXT $page The codename of the page to load
     * @return boolean Whether the page should be omitted.
     */
    protected function _is_page_omitted_from_sitemap($zone, $page)
    {
        // Some kinds of hidden pages
        if (substr($page, 0, 6) == 'panel_') {
            return true;
        }
        if (substr($page, 0, 1) == '_') {
            return true;
        }
        if ($page == '404') {
            return true;
        }
        if ($page == 'sitemap') {
            return true;
        }

        // Pages shown in the footer should not repeat in the Sitemap
        if ((get_option('bottom_show_privacy_link') == '1') && ($page == 'privacy')) {
            return true;
        }
        if ((get_option('bottom_show_rules_link') == '1') && ($page == 'rules') && (($zone == '') || ($zone == 'site') || ($zone == 'forum'))) {
            return true;
        }
        if ((get_option('bottom_show_feedback_link') == '1') && ($page == 'feedback')) {
            return true;
        }

        // Disabled via a looped redirect?
        $test = _request_page__redirects($page, $zone);
        if ($test !== false) {
            return true;
        }

        // Note that other things are disabled via get_entry_points returning null

        return false;
    }

    /**
     * Find whether the hook is active.
     *
     * @return boolean Whether the hook is active.
     */
    public function is_active()
    {
        return true;
    }

    /**
     * Remap '_SEARCH' zones if we can derive the zone from the page-link / or fix _SEARCH in the page-link if there's a known zone.
     *
     * @param  ID_TEXT $zone The zone in the recurse tree (replaced by reference).
     * @param  ID_TEXT $page_link The page-link (replaced by reference).
     * @return ID_TEXT The page name (only returned because it could also be useful, saves some code).
     */
    protected function _make_zone_concrete(&$zone, &$page_link)
    {
        $matches = array();
        preg_match('#^([^:]*):([^:]*)#', $page_link, $matches);
        $page = $matches[2];

        if ($zone == '_SEARCH') { // Make zone concrete, from page-link
            if ($matches[1] === '_SEARCH') { // Do a search even, if we're desperate
                $zone = get_page_zone($page); // $page_link was unknown, $zone was unknown
            } else {
                $zone = $matches[1]; // $page_link was known, $zone was unknown, we assume $page_link can contain no error
            }
        } else {
            if ($matches[1] == '_SEARCH') { // Test zone, fix up if necessary
                // $page_link was unknown, $zone was known
                $details = $this->_request_page_details($page, $zone);
                if ($details === false) { // Do a search, if we're desperate
                    $zone = get_page_zone($page); // $page_link was unknown, $zone was known, but $zone was wrong
                }
            } elseif ($matches[1] != $zone) { // Correct the zone from what is in the page-link
                $zone = $matches[1]; // $page_link was known, $zone was known, but mismatch so assume $zone was wrong
            } // else change nothing ($page_link was known, $zone was known)
        }

        if (($zone == 'site') && (get_option('collapse_user_zones') == '1')) {
            $zone = '';
        }

        // Correct the page-link from the zone
        $page_link = preg_replace('#^_SEARCH(:|$)#', $zone . '${1}', $page_link);

        return $page;
    }

    /**
     * Find if a page-link will be covered by this node.
     *
     * @param  ID_TEXT $page_link The page-link.
     * @return integer A SITEMAP_NODE_* constant.
     */
    abstract public function handles_page_link($page_link);

    /**
     * Get a particular Sitemap object. Used for easily tying in a different kind of child node.
     *
     * @param  ID_TEXT $hook The hook, i.e. the Sitemap object type. Usually the same as a content type.
     * @return object The Sitemap object.
     */
    protected function _get_sitemap_object($hook)
    {
        require_code('hooks/systems/sitemap/' . filter_naughty_harsh($hook, true));
        return object_factory('Hook_sitemap_' . filter_naughty_harsh($hook, true));
    }

    /**
     * Find all nodes at the top level position in the Sitemap for this hook.
     * May be a single node (i.e. a category root) or multiple nodes (if there's a flat structure).
     *
     * @param  ID_TEXT $page_link The page-link we are finding.
     * @param  ?string $callback Callback function to send discovered page-links to (null: return).
     * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
     * @param  ?integer $child_cutoff Maximum number of children before we cut off all children (null: no limit).
     * @param  ?integer $max_recurse_depth How deep to go from the Sitemap root (null: no limit).
     * @param  integer $recurse_level Our recursion depth (used to limit recursion, or to calculate importance of page-link, used for instance by XML Sitemap [deeper is typically less important]).
     * @param  integer $options A bitmask of SITEMAP_GEN_* options.
     * @param  ID_TEXT $zone The zone we will consider ourselves to be operating in (needed due to transparent redirects feature)
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     * @param  boolean $return_anyway Whether to return the structure even if there was a callback. Do not pass this setting through via recursion due to memory concerns, it is used only to gather information to detect and prevent parent/child duplication of default entry points.
     * @return ?array List of node structures (null: working via callback).
     */
    public function get_virtual_nodes($page_link, $callback = null, $valid_node_types = null, $child_cutoff = null, $max_recurse_depth = null, $recurse_level = 0, $options = 0, $zone = '_SEARCH', $meta_gather = 0, $return_anyway = false)
    {
        $nodes = ($callback === null || $return_anyway) ? array() : mixed();

        return $nodes;
    }

    /**
     * Find details of a position in the Sitemap.
     *
     * @param  ID_TEXT $page_link The page-link we are finding.
     * @param  ?string $callback Callback function to send discovered page-links to (null: return).
     * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
     * @param  ?integer $child_cutoff Maximum number of children before we cut off all children (null: no limit).
     * @param  ?integer $max_recurse_depth How deep to go from the Sitemap root (null: no limit).
     * @param  integer $recurse_level Our recursion depth (used to limit recursion, or to calculate importance of page-link, used for instance by XML Sitemap [deeper is typically less important]).
     * @param  integer $options A bitmask of SITEMAP_GEN_* options.
     * @param  ID_TEXT $zone The zone we will consider ourselves to be operating in (needed due to transparent redirects feature)
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     * @param  ?array $row Database row (null: lookup).
     * @param  boolean $return_anyway Whether to return the structure even if there was a callback. Do not pass this setting through via recursion due to memory concerns, it is used only to gather information to detect and prevent parent/child duplication of default entry points.
     * @return ?array Node structure (null: working via callback / error).
     */
    abstract public function get_node($page_link, $callback = null, $valid_node_types = null, $child_cutoff = null, $max_recurse_depth = null, $recurse_level = 0, $options = 0, $zone = '_SEARCH', $meta_gather = 0, $row = null, $return_anyway = false);

    /**
     * Make sure a Sitemap page-link is not recursively being evaluated due to some kind of issue (e.g. a cyclic category structure or a bug).
     *
     * @param  ID_TEXT $page_link The page-link we are finding.
     * @return boolean Whether are are okay, not looping.
     */
    protected function check_for_looping($page_link)
    {
        global $IS_SITEMAP_STRUCTURE_LOOPING;

        $sz = serialize(array($page_link, get_class($this)));

        if (isset($IS_SITEMAP_STRUCTURE_LOOPING[$sz])) {
            return false;
        }

        $IS_SITEMAP_STRUCTURE_LOOPING[$sz] = true;
        return true;
    }

    /**
     * Check the permissions of the node structure, returning false if they fail for the current user.
     *
     * @param  array $struct Node structure
     * @return boolean Whether the permissions pass
     */
    protected function _check_node_permissions($struct)
    {
        // Check defined permissions
        foreach ($struct['permissions'] as $permission) {
            switch ($permission['type']) {
                case 'non_guests':
                    if (is_guest(get_member())) {
                        return false;
                    }
                    break;

                case 'zone':
                    if (!has_zone_access(get_member(), $permission['zone_name'])) {
                        return false;
                    }
                    break;

                case 'page':
                    if (!has_page_access(get_member(), $permission['page_name'], $permission['zone_name'])) {
                        return false;
                    }
                    break;

                case 'category':
                    if (!has_category_access(get_member(), $permission['permission_module'], $permission['category_name'])) {
                        return false;
                    }
                    break;
            }
        }

        // Checked implicit match-key permissions
        $matches = array();
        $page_link = $struct['page_link'];
        if (preg_match('#^([^:]*):([^:]*):([^:]*)#', $page_link, $matches) != 0) {
            $zone = $matches[1];
            $page = $matches[2];
            $type = $matches[3];

            $groups = _get_where_clause_groups(get_member(), false);
            if ($groups !== null) {
                list(, $params) = page_link_decode($page_link);

                $groups2 = filter_group_permissivity($GLOBALS['FORUM_DRIVER']->get_members_groups(get_member(), false));

                global $HAS_MANY_MATCH_KEYS, $MATCH_KEYS_CACHED;
                if ($HAS_MANY_MATCH_KEYS === null) {
                    $HAS_MANY_MATCH_KEYS = ($GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) FROM ' . get_table_prefix() . 'group_page_access WHERE page_name LIKE \'' . db_encode_like('%:%') . '\'') > 50);
                }
                if ($HAS_MANY_MATCH_KEYS) {
                    $pg_where = '1=0';
                    $pg_where .= ' OR page_name LIKE \'' . db_encode_like('\_WILD:' . $page . ':%') . '\'';
                    $pg_where .= ' OR page_name LIKE \'' . db_encode_like($zone . ':' . $page . ':%') . '\'';
                    $pg_where .= ' OR page_name LIKE \'' . db_encode_like('\_WILD:\_WILD:%') . '\'';
                    $pg_where .= ' OR page_name LIKE \'' . db_encode_like($zone . ':\_WILD:%') . '\'';
                    $perhaps = $GLOBALS['SITE_DB']->query('SELECT * FROM ' . get_table_prefix() . 'group_page_access WHERE (' . $pg_where . ') AND (' . $groups . ')', null, null, false, true);
                } else {
                    // Optimisation, for when there are not a lot of match keys
                    if ($MATCH_KEYS_CACHED === null) {
                        $pg_where = 'page_name LIKE \'' . db_encode_like('%:%') . '\'';
                        $MATCH_KEYS_CACHED = $GLOBALS['SITE_DB']->query('SELECT * FROM ' . get_table_prefix() . 'group_page_access WHERE (' . $pg_where . ') AND (' . $groups . ')', null, null, false, true);
                    }
                    $perhaps = $MATCH_KEYS_CACHED;
                }

                $denied_groups = array();
                foreach ($groups2 as $group) {
                    foreach ($perhaps as $praps) {
                        if (($praps['group_id'] == $group) && ($praps['zone_name'] == '/')) {
                            if (match_key_match($praps['page_name'], true, $params, $zone, $page)) {
                                $denied_groups[$group] = true;
                            }
                        }
                    }
                }

                if (count($denied_groups) == count($groups2)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Get the permission page that nodes matching $page_link in this hook are tied to.
     * The permission page is where privileges may be overridden against.
     *
     * @param  string $page_link The page-link
     * @return ?ID_TEXT The permission page (null: none)
     */
    public function get_privilege_page($page_link)
    {
        return null;
    }

    /**
     * Convert a page-link to a category ID and category permission module type.
     *
     * @param  string $page_link The page-link
     * @return ?array The pair (null: permission modules not handled)
     */
    public function extract_child_page_link_permission_pair($page_link)
    {
        return null;
    }

    /**
     * Find details for this node.
     *
     * @param  ?array $row Faked database row (null: derive).
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     * @param  ID_TEXT $zone The zone.
     * @param  ID_TEXT $page The page.
     * @param  ID_TEXT $type The type.
     * @param  ?ID_TEXT $id The ID (null: unknown).
     * @return ?array Faked database row (null: derive).
     */
    protected function _load_row_from_page_groupings($row, $meta_gather, $zone, $page, $type = 'browse', $id = null)
    {
        if (!isset($row[0])) { // If the first tuple element is not defined (a property map may be, for Comcode pages)
            // Find from page grouping

            if ($row === null) {
                $row = array();
            }

            $links = get_page_grouping_links();
            foreach ($links as $link) {
                if ($link === null) {
                    continue;
                }

                if (!is_array($link[2])) {
                    continue;
                }

                $is_a_match = true;
                if ($link[2][0] != $page) {
                    $is_a_match = false;
                } else {
                    if ((isset($link[2][1]['type'])) && ($link[2][1]['type'] != $type)) {
                        $is_a_match = false;
                    } else {
                        if (($link[2][2] != $zone) && ($page == 'start')) {
                            $is_a_match = false;
                        } else {
                            if ((isset($link[2][1]['id'])) && ($link[2][1]['id'] !== $id)) {
                                $is_a_match = false;
                            }
                        }
                    }
                }
                if ($is_a_match) {
                    $title = $link[3];
                    $icon = $link[1];

                    $description = null;
                    if ((isset($link[4])) && (($meta_gather & SITEMAP_GATHER_DESCRIPTION) != 0)) {
                        $description = (is_object($link[4])) ? $link[4] : comcode_lang_string($link[4]);
                    }

                    $row = array($title, $icon, $description) + $row;

                    if ($link[2][2] == $zone) {
                        break; // If was a perfect match, break out
                    }
                }
            }

            if ($row === null) { // Get from editable menus?
                $test = $GLOBALS['SITE_DB']->query_select('menu_items', array('*'), array('i_url' => $zone . ':' . $page), '', 1);
                if (array_key_exists(0, $test)) {
                    $title = get_translated_tempcode('menu_items', $test[0], 'i_caption');
                    $icon = $test[0]['i_theme_img_code'];
                    $description = get_translated_tempcode('menu_items', $test[0], 'i_caption_long');
                    $row += array($title, $icon, $description);
                }
            }
        }

        return $row;
    }

    /**
     * Take some text and process it as a language string if applicable, otherwise just convert to Tempcode.
     *
     * @param  string $_title Input string
     * @return Tempcode Output string
     */
    protected function _lang_string_or_literal($_title)
    {
        if ((preg_match('#^[A-Z\_]+$#', $_title) == 0) || (do_lang($_title, null, null, null, null, false) === null)) {
            $title = make_string_tempcode($_title);
        } else {
            $title = protect_from_escaping(do_lang($_title))/*Need to bake it in using do_lang not do_lang_tempcode as lang file may not be present when restored from cache*/;
        }
        return $title;
    }

    /**
     * Extend the node structure with added details from our row data (if we have it).
     *
     * @param  integer $options A bitmask of SITEMAP_GEN_* options.
     * @param  array $struct Structure.
     * @param  ?array $row Faked database row (null: we don't have row data).
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     */
    protected function _ameliorate_with_row($options, &$struct, $row, $meta_gather)
    {
        if (array_key_exists(0, $row)) {
            $title = $row[0];
            $icon = $row[1];
            $description = $row[2];

            if (($options & SITEMAP_GEN_LABEL_CONTENT_TYPES) == 0) {
                if ($title !== null) {
                    if (is_string($title)) {
                        $title = $this->_lang_string_or_literal($title);
                    }

                    if (!$title->is_empty()) {
                        $struct['title'] = $title;
                    }
                }
            }

            if ($description !== null) {
                if (($meta_gather & SITEMAP_GATHER_DESCRIPTION) != 0) {
                    if (!isset($struct['extra_meta']['description'])) {
                        if (is_string($description)) {
                            $description = $this->_lang_string_or_literal($description);
                        }

                        $struct['extra_meta']['description'] = ($description === null) ? null : $description;
                    }
                }
            }

            if ($icon !== null) {
                if (($meta_gather & SITEMAP_GATHER_IMAGE) != 0) {
                    if (!isset($struct['extra_meta']['image'])) {
                        $struct['extra_meta']['image'] = ($icon === null) ? null : find_theme_image('icons/24x24/' . $icon);
                        $struct['extra_meta']['image_2x'] = ($icon === null) ? null : find_theme_image('icons/48x48/' . $icon);
                    }
                }
            }
        }
    }
}

/**
 * Sitemap node type for content types.
 *
 * @package        core
 */
abstract class Hook_sitemap_content extends Hook_sitemap_base
{
    protected $content_type = null;
    protected $entry_content_type = null;
    protected $entry_sitetree_hook = null;
    protected $cma_info = null;
    protected $screen_type = 'browse';

    /**
     * Find if a page-link will be covered by this node.
     *
     * @param  ID_TEXT $page_link The page-link.
     * @return integer A SITEMAP_NODE_* constant.
     */
    public function handles_page_link($page_link)
    {
        $matches = array();
        if (preg_match('#^([^:]*):([^:]*)#', $page_link, $matches) != 0) {
            $zone = $matches[1];
            $page = $matches[2];

            $cma_info = $this->_get_cma_info();
            require_code('site');
            if (($cma_info !== null) && ($cma_info['module'] == $page) && (($zone == '_SEARCH') || (_request_page($page, $zone) !== false))) { // Ensure the given page matches the content type, and it really does exist in the given zone
                if ($matches[0] == $page_link) {
                    return SITEMAP_NODE_HANDLED_VIRTUALLY; // No type/ID specified
                }
                if (preg_match('#^([^:]*):([^:]*):' . $this->screen_type . '(:|$)#', $page_link, $matches) != 0) {
                    return SITEMAP_NODE_HANDLED;
                }
            }
        }
        return SITEMAP_NODE_NOT_HANDLED;
    }

    /**
     * Get a content ID via a page-link.
     *
     * @param  ID_TEXT $page_link The page-link.
     * @return ?ID_TEXT The ID (null: unknown).
     */
    protected function _get_page_link_id($page_link)
    {
        $matches = array();
        if (preg_match('#^([^:]*):([^:]*):([^:]*):([^:]*)#', $page_link, $matches) == 0) {
            return null;
        }
        return $matches[4];
    }

    /**
     * Get the CMA info for our content hook.
     *
     * @return ?array The CMA info (null: none).
     */
    protected function _get_cma_info()
    {
        if ($this->cma_info === null) {
            require_code('content');
            $cma_ob = get_content_object($this->content_type);
            $this->cma_info = $cma_ob->info();
        }
        return $this->cma_info;
    }

    /**
     * Get the database row for some content.
     *
     * @param  ID_TEXT $content_id The content ID.
     * @return array The content row.
     */
    protected function _get_row($content_id)
    {
        $cma_info = $this->_get_cma_info();
        return content_get_row($content_id, $cma_info);
    }

    /**
     * Pre-fill part of the node structure, from what we know from the CMA hook.
     *
     * @param  ID_TEXT $page_link The page-link we are finding.
     * @param  ?string $callback Callback function to send discovered page-links to (null: return).
     * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
     * @param  ?integer $child_cutoff Maximum number of children before we cut off all children (null: no limit).
     * @param  ?integer $max_recurse_depth How deep to go from the Sitemap root (null: no limit).
     * @param  integer $recurse_level Our recursion depth (used to limit recursion, or to calculate importance of page-link, used for instance by XML Sitemap [deeper is typically less important]).
     * @param  integer $options A bitmask of SITEMAP_GEN_* options.
     * @param  ID_TEXT $zone The zone we will consider ourselves to be operating in (needed due to transparent redirects feature)
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     * @param  ?array $row Database row (null: lookup).
     * @return ?array A tuple: content ID, row, partial node structure (null: filtered).
     */
    protected function _create_partial_node_structure($page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, $recurse_level, $options, $zone, $meta_gather, $row)
    {
        if (($valid_node_types !== null) && (!in_array($this->content_type, $valid_node_types))) {
            return null;
        }

        $cma_info = $this->_get_cma_info();
        if ($cma_info === null) {
            return null;
        }

        $content_id = $this->_get_page_link_id($page_link);
        if ($content_id === null) {
            return null;
        }
        if ($row === null) {
            $row = $this->_get_row($content_id);
            if ($row === null) {
                return null;
            }
        }

        if (strpos($cma_info['title_field'], 'CALL:') !== false) {
            $title_value = call_user_func(trim(substr($cma_info['title_field'], 5)), array('id' => $content_id), false);
            $title = make_string_tempcode(escape_html($title_value));
        } else {
            $title_value = $row[$cma_info['title_field']];
            if (isset($cma_info['title_field_supports_comcode']) && $cma_info['title_field_supports_comcode']) {
                if ($cma_info['title_field_dereference']) {
                    $title = get_translated_tempcode($cma_info['table'], $row, $cma_info['title_field'], $cma_info['connection']);
                } else {
                    $title = comcode_to_tempcode($title_value, $GLOBALS['FORUM_DRIVER']->get_guest_id());
                }
            } else {
                $title = make_string_tempcode(escape_html(($cma_info['title_field_dereference'] && is_integer($title_value)) ? get_translated_text($title_value, $cma_info['connection']) : $title_value));
            }
        }

        $matches = array();
        preg_match('#^([^:]*):([^:]*):([^:]*):([^:]*)#', $page_link, $matches);
        if ($matches[1] != $zone) {
            if ($zone == '_SEARCH') {
                $zone = $matches[1];
            } else {
                warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }
        }
        $page = $matches[2];

        $has_entries = ($cma_info['is_category']) && ($this->entry_content_type !== null);
        $has_subcategories = (!is_null($cma_info['parent_spec__parent_name'])) && ($cma_info['parent_category_meta_aware_type'] == $this->content_type);

        $struct = array(
            'title' => $title,
            'content_type' => $this->content_type,
            'content_id' => $content_id,
            'modifiers' => array(),
            'only_on_page' => '',
            'page_link' => $page_link,
            'url' => null,
            'extra_meta' => array(
                'description' => null,
                'image' => null,
                'image_2x' => null,
                'add_date' => null,
                'edit_date' => null,
                'submitter' => null,
                'views' => null,
                'rating' => null,
                'meta_keywords' => null,
                'meta_description' => null,
                'categories' => null,
                'validated' => null,
                'db_row' => null,
            ),
            'permissions' => array(
                array(
                    'type' => 'zone',
                    'zone_name' => $zone,
                    'is_owned_at_this_level' => false,
                ),
                array(
                    'type' => 'page',
                    'zone_name' => $zone,
                    'page_name' => $page,
                    'is_owned_at_this_level' => false,
                ),
            ),
            'has_possible_children' => $has_entries || $has_subcategories,
            'children' => null,

            // These are likely to be changed in individual hooks
            'sitemap_priority' => SITEMAP_IMPORTANCE_MEDIUM,
            'sitemap_refreshfreq' => 'monthly',
        );

        if (!is_null($cma_info['permissions_type_code'])) {
            if (is_array($cma_info['category_field'])) {
                $cma_info['category_field'] = array_pop($cma_info['category_field']);
            }
            $struct['permissions'][] = array(
                'type' => 'category',
                'permission_module' => $cma_info['permissions_type_code'],
                'category_name' => @strval($cma_info['is_entry'] ? $row[$cma_info['category_field']] : $content_id),
                'page_name' => $page,
                'is_owned_at_this_level' => true,
            );
        }

        /* Description field generally not appropriate for Sitemap
        if ((($meta_gather & SITEMAP_GATHER_DESCRIPTION) != 0) && (!is_null($cma_info['description_field']))) {
            $description = $row[$cma_info['description_field']];
            if (is_integer($description)) {
                $struct['extra_meta']['description'] = get_translated_tempcode($description, $cma_info['connection']);
            } else {
                $struct['extra_meta']['description'] = make_string_tempcode(escape_html($description));
            }
        }
        */

        if ((($meta_gather & SITEMAP_GATHER_IMAGE) != 0) && (!is_null($cma_info['thumb_field']))) {
            if (method_exists($this, '_find_theme_image')) {
                $this->_find_theme_image($row, $struct);
            } else {
                if (strpos($cma_info['thumb_field'], 'CALL:') !== false) {
                    $struct['extra_meta']['image'] = call_user_func(trim(substr($cma_info['thumb_field'], 5)), array('id' => $content_id), $row);
                } else {
                    $struct['extra_meta']['image'] = $row[$cma_info['thumb_field']];
                }
                if ($struct['extra_meta']['image'] == '') {
                    $struct['extra_meta']['image'] = null;
                } else {
                    if ($cma_info['thumb_field_is_theme_image']) {
                        $struct['extra_meta']['image'] = find_theme_image($struct['extra_meta']['image'], true);
                    } else {
                        if (url_is_local($struct['extra_meta']['image'])) {
                            $struct['extra_meta']['image'] = get_custom_base_url() . '/' . $struct['extra_meta']['image'];
                        }
                    }
                }
            }
        }

        if (($meta_gather & SITEMAP_GATHER_TIMES) != 0) {
            if (!is_null($cma_info['add_time_field'])) {
                $struct['extra_meta']['add_time'] = $row[$cma_info['add_time_field']];
            }

            if (!is_null($cma_info['edit_time_field'])) {
                $struct['extra_meta']['edit_time'] = $row[$cma_info['edit_time_field']];
            }
        }

        if ((($meta_gather & SITEMAP_GATHER_SUBMITTER) != 0) && (!is_null($cma_info['submitter_field']))) {
            $struct['extra_meta']['submitter'] = $row[$cma_info['submitter_field']];
        }

        if ((($meta_gather & SITEMAP_GATHER_AUTHOR) != 0) && (!is_null($cma_info['author_field']))) {
            $struct['extra_meta']['author'] = $row[$cma_info['author_field']];
        }

        if ((($meta_gather & SITEMAP_GATHER_VIEWS) != 0) && (!is_null($cma_info['views_field']))) {
            $struct['extra_meta']['views'] = $row[$cma_info['views_field']];
        }

        if ((($meta_gather & SITEMAP_GATHER_RATING) != 0) && (!is_null($cma_info['feedback_type_code']))) {
            $rating = $GLOBALS['SITE_DB']->query_select_value('rating', 'AVG(rating)', array('rating_for_type' => $cma_info['feedback_type_code'], 'rating_for_id' => $content_id));
            $struct['extra_meta']['rating'] = $rating;
        }

        if ((($meta_gather & SITEMAP_GATHER_NUM_COMMENTS) != 0) && (!is_null($cma_info['feedback_type_code']))) {
            $num_comments = 0;
            $_comments = $GLOBALS['FORUM_DRIVER']->get_forum_topic_posts($GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier(get_option('comments_forum_name'), $cma_info['feedback_type_code'] . '_' . $content_id, do_lang('COMMENT')), $num_comments, 0, 0, false);

            $struct['extra_meta']['num_comments'] = $num_comments;
        }

        if ((($meta_gather & SITEMAP_GATHER_META) != 0) && (!is_null($cma_info['seo_type_code']))) {
            list($struct['extra_meta']['meta_keywords'], $struct['extra_meta']['meta_description']) = seo_meta_get_for($this->content_type, $content_id);
        }

        if ((($meta_gather & SITEMAP_GATHER_VALIDATED) != 0) && (!is_null($cma_info['validated_field']))) {
            $struct['extra_meta']['validated'] = $row[$cma_info['validated_field']];
        }

        if (($meta_gather & SITEMAP_GATHER_DB_ROW) != 0) {
            $struct['extra_meta']['db_row'] = $row;
        }

        return array($content_id, $row, $struct);
    }

    /**
     * Find what fields we should select for the Sitemap to be buildable. We don't want to select too much for performance reasons.
     * Also find out what language fields we should load up for the table (returned by reference).
     *
     * @param  ?array $cma_info CMA info (null: standard for this hook)
     * @param  string $table_prefix Table prefix
     * @param  ?array $lang_fields_filtered List of language fields to load (null: not passed)
     * @return array Map between field name and field type
     */
    protected function select_fields($cma_info = null, $table_prefix = '', &$lang_fields_filtered = null)
    {
        if ($cma_info === null) {
            $cma_info = $this->_get_cma_info();
        }

        $cma_fields = array(
            'id_field',
            'title_field',
            'category_field',
            'thumb_field',
            'add_time_field',
            'edit_time_field',
            'submitter_field',
            'author_field',
            'views_field',
            'validated_field',
        );
        $select = array();
        foreach ($cma_fields as $cma_field) {
            if ($cma_info[$cma_field] !== null) {
                if (is_array($cma_info[$cma_field])) {
                    foreach ($cma_info[$cma_field] as $f) {
                        if ($table_prefix != '') {
                            $f = $table_prefix . '.' . $f;
                        }
                        $select[] = $f;
                    }
                } elseif (strpos($cma_info[$cma_field], 'CALL:') === false) {
                    $f = $cma_info[$cma_field];
                    if ($table_prefix != '') {
                        $f = $table_prefix . '.' . $f;
                    }
                    $select[] = $f;
                }
            }
        }

        $table = $cma_info['table'];

        global $TABLE_LANG_FIELDS_CACHE;
        $lang_fields = isset($TABLE_LANG_FIELDS_CACHE[$table]) ? $TABLE_LANG_FIELDS_CACHE[$table] : array();
        $lang_fields_filtered = array();
        foreach ($lang_fields as $field => $type) {
            $f = $field;
            if ($table_prefix != '') {
                $f = $table_prefix . '.' . $f;
            }
            if (in_array($f, $select)) {
                $lang_fields_filtered[$table_prefix . '.' . $field] = $type;
            }
        }

        return $select;
    }

    /**
     * Get a list of child nodes, from what we know from the CMA hook.
     *
     * @param  ID_TEXT $content_id The content ID.
     * @param  ID_TEXT $page_link The page-link we are finding.
     * @param  ?string $callback Callback function to send discovered page-links to (null: return).
     * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
     * @param  ?integer $child_cutoff Maximum number of children before we cut off all children (null: no limit).
     * @param  ?integer $max_recurse_depth How deep to go from the Sitemap root (null: no limit).
     * @param  integer $recurse_level Our recursion depth (used to limit recursion, or to calculate importance of page-link, used for instance by XML Sitemap [deeper is typically less important]).
     * @param  integer $options A bitmask of SITEMAP_GEN_* options.
     * @param  ID_TEXT $zone The zone we will consider ourselves to be operating in (needed due to transparent redirects feature)
     * @param  integer $meta_gather A bitmask of SITEMAP_GATHER_* constants, of extra data to include.
     * @param  ?array $row Database row (null: lookup).
     * @param  string $extra_where_entries Extra SQL piece for considering which entries to load.
     * @param  ?string $explicit_order_by_entries Order by for entries (null: alphabetical title).
     * @param  ?string $explicit_order_by_subcategories Order by for categories (null: alphabetical title).
     * @return ?array Child nodes (null: not retrieved yet).
     */
    protected function _get_children_nodes($content_id, $page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, $recurse_level, $options, $zone, $meta_gather, $row, $extra_where_entries = '', $explicit_order_by_entries = null, $explicit_order_by_subcategories = null)
    {
        if ((!is_null($max_recurse_depth)) && ($recurse_level >= $max_recurse_depth)) {
            return null;
        }

        $this->_make_zone_concrete($zone, $page_link);

        $cma_info = $this->_get_cma_info();
        if ($cma_info === null) {
            return null;
        }

        $matches = array();
        preg_match('#^([^:]*):([^:]*):([^:]*):([^:]*)#', $page_link, $matches);
        $page = $matches[2];

        $children = array();

        $has_entries = ($cma_info['is_category']) && ($this->entry_content_type !== null);
        $has_subcategories = (!is_null($cma_info['parent_spec__parent_name'])) && ($cma_info['parent_category_meta_aware_type'] == $this->content_type);
        if (!$has_entries && !$has_subcategories) {
            return null;
        }

        $require_permission_support = (($options & SITEMAP_GEN_REQUIRE_PERMISSION_SUPPORT) != 0);

        $consider_validation = (($options & SITEMAP_GEN_CONSIDER_VALIDATION) != 0);

        // Entries...
        if ($has_entries) {
            for ($i = 0; $i < count($this->entry_content_type); $i++) {
                $entry_content_type = $this->entry_content_type[$i];
                $entry_sitetree_hook = $this->entry_sitetree_hook[$i];

                require_code('content');
                $cma_entry_ob = get_content_object($entry_content_type);
                $cma_entry_info = $cma_entry_ob->info();

                if ((!$require_permission_support) || (($cma_entry_info['permissions_type_code'] !== null) && ($cma_entry_info['is_category']/*Unlikely to be true!*/) && (!$cma_entry_info['is_entry']))) {
                    $child_hook_ob = $this->_get_sitemap_object($entry_sitetree_hook);

                    $children_entries = array();

                    $privacy_join = '';
                    $privacy_where = '';
                    if ($cma_entry_info['support_privacy']) {
                        if (addon_installed('content_privacy')) {
                            require_code('content_privacy');
                            list($privacy_join, $privacy_where) = get_privacy_where_clause($entry_content_type, 'r');
                        }
                    }

                    $where = array();
                    if (is_array($cma_entry_info['category_field'])) {
                        $cma_entry_info['category_field'] = array_pop($cma_entry_info['category_field']);
                    }
                    $where[$cma_entry_info['category_field']] = $cma_info['id_field_numeric'] ? intval($content_id) : $content_id;
                    if (($consider_validation) && (!is_null($cma_entry_info['validated_field']))) {
                        $where[$cma_entry_info['validated_field']] = 1;
                    }
                    $table = $cma_entry_info['table'] . ' r';
                    $table .= $privacy_join;

                    $lang_fields = array();
                    $select = $this->select_fields($cma_entry_info, 'r', $lang_fields);

                    if (substr($cma_entry_info['table'], 0, 2) == 'f_') {
                        $db = $GLOBALS['FORUM_DB'];
                    } else {
                        $db = $GLOBALS['SITE_DB'];
                    }

                    $max_rows_per_loop = ($child_cutoff === null) ? SITEMAP_MAX_ROWS_PER_LOOP : min($child_cutoff + 1, SITEMAP_MAX_ROWS_PER_LOOP);

                    $start = 0;
                    do {
                        $rows = $cma_entry_info['connection']->query_select($table, $select, $where, $extra_where_entries . $privacy_where . (is_null($explicit_order_by_entries) ? '' : (' ORDER BY ' . $explicit_order_by_entries)), $max_rows_per_loop, $start, false, $lang_fields);

                        if (($start == 0) && ($child_cutoff !== null) && (count($rows) > $child_cutoff)) {
                            $rows = array(); // Too many to process. We don't do with a COUNT(*) query because on balance of probability there won't be too many child rows and we can save a count query at the cost of the small risk of loading excess data
                        }

                        $child_page = ($cma_entry_info['module'] == $cma_info['module']) ? $page : $cma_entry_info['module']; // assumed in same zone
                        foreach ($rows as $child_row) {
                            $child_page_link = $zone . ':' . $child_page . ':' . $child_hook_ob->screen_type . ':' . ($cma_entry_info['id_field_numeric'] ? strval($child_row[$cma_entry_info['id_field']]) : $child_row[$cma_entry_info['id_field']]);
                            $child_node = $child_hook_ob->get_node($child_page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, $recurse_level + 1, $options, $zone, $meta_gather, $child_row);
                            if ($child_node !== null) {
                                $children_entries[] = $child_node;
                            }
                        }
                        $start += $max_rows_per_loop;
                    } while (count($rows) == $max_rows_per_loop);

                    if (is_null($explicit_order_by_entries)) {
                        sort_maps_by($children_entries, 'title');
                    }
                    $children = array_merge($children, $children_entries);
                }
            }
        }

        // Subcategories...
        if ($has_subcategories) {
            $children_categories = array();

            $where = array();
            $where[$cma_info['parent_spec__parent_name']] = $cma_info['category_is_string'] ? $content_id : intval($content_id);
            if (($consider_validation) && (!is_null($cma_info['validated_field']))) {
                $where[$cma_info['validated_field']] = 1;
            }

            $table = $cma_info['parent_spec__table_name'] . ' r';

            $lang_fields = array();
            if ($cma_info['parent_spec__table_name'] != $cma_info['table']) {
                $select = $this->select_fields($cma_info, 'r2', $lang_fields);
                $select[] = 'r.' . $cma_info['parent_spec__field_name'];
                $table .= ' JOIN ' . $cma_info['connection']->get_table_prefix() . $cma_info['table'] . ' r2 ON r2.' . $cma_info['id_field'] . '=r.' . $cma_info['parent_spec__field_name'];
            } else {
                $select = $this->select_fields($cma_info, 'r', $lang_fields);
            }

            if (substr($cma_info['table'], 0, 2) == 'f_') {
                $db = $GLOBALS['FORUM_DB'];
            } else {
                $db = $GLOBALS['SITE_DB'];
            }

            $max_rows_per_loop = ($child_cutoff === null) ? SITEMAP_MAX_ROWS_PER_LOOP : min($child_cutoff + 1, SITEMAP_MAX_ROWS_PER_LOOP);

            $start = 0;
            do {
                $rows = $cma_info['connection']->query_select($table, $select, $where, (is_null($explicit_order_by_subcategories) ? '' : ('ORDER BY ' . $explicit_order_by_subcategories)), $max_rows_per_loop, $start, false, $lang_fields);

                if (($start == 0) && ($child_cutoff !== null) && (count($rows) > $child_cutoff)) {
                    $rows = array(); // Too many to process. We don't do with a COUNT(*) query because on balance of probability there won't be too many child rows and we can save a count query at the cost of the small risk of loading excess data
                }

                foreach ($rows as $child_row) {
                    // FUDGE
                    if (($table == 'galleries r') && (addon_installed('galleries')) && (get_option('show_empty_galleries') == '0')) {
                        require_code('galleries');
                        if (!gallery_has_content($child_row['name'])) {
                            continue;
                        }
                    }

                    if ($this->content_type == 'comcode_page') {
                        $child_page_link = $zone . ':' . $child_row['the_page'];
                    } else {
                        $child_page_link = $zone . ':' . $page . ':' . $this->screen_type . ':' . ($cma_info['category_is_string'] ? $child_row[$cma_info['parent_spec__field_name']] : strval($child_row[$cma_info['parent_spec__field_name']]));
                    }
                    $child_node = $this->get_node($child_page_link, $callback, $valid_node_types, $child_cutoff, $max_recurse_depth, $recurse_level + 1, $options, $zone, $meta_gather, $child_row);
                    if ($child_node !== null) {
                        $children_categories[] = $child_node;
                    }
                }
                $start += $max_rows_per_loop;
            } while (count($rows) == $max_rows_per_loop);

            if (is_null($explicit_order_by_subcategories)) {
                sort_maps_by($children_categories, 'title');
            }
            $children = array_merge($children, $children_categories);
        }

        return $children;
    }

    /**
     * Convert a page-link to a category ID and category permission module type.
     *
     * @param  ID_TEXT $page_link The page-link
     * @return ?array The pair (null: permission modules not handled)
     */
    public function extract_child_page_link_permission_pair($page_link)
    {
        $matches = array();
        preg_match('#^([^:]*):([^:]*):browse:(.*)$#', $page_link, $matches);
        $id = $matches[3];

        $cma_info = $this->_get_cma_info();

        return array($id, $cma_info['permissions_type_code']);
    }
}

/**
 * Get all the details (links) of our page groupings.
 *
 * @return array List of link tuples (one of the elements of which defines the page grouping -- see the page grouping hooks to see the structure).
 */
function get_page_grouping_links()
{
    static $links = null;
    if ($links === null) {
        $links = array();

        $hooks = find_all_hooks('systems', 'page_groupings');
        foreach (array_keys($hooks) as $hook) {
            require_code('hooks/systems/page_groupings/' . $hook);

            $ob = object_factory('Hook_page_groupings_' . $hook);
            $links = array_merge($links, $ob->run());
        }
    }
    return $links;
}

/**
 * Get Comcode pages from a zone, that sit in the root of that zone.
 *
 * @param  ID_TEXT $zone The zone to get for.
 * @param  boolean $include_zone Use page-links in the mapping rather than just page names.
 * @return array Root Comcode pages, mapping page name to validation status.
 */
function get_root_comcode_pages($zone, $include_zone = false)
{
    /*
    $rows[$zone] = $GLOBALS['SITE_DB']->query_select('comcode_pages', array('the_page', 'p_validated'), array('the_zone' => $zone, 'p_parent_page' => ''));
    return collapse_2d_complexity('the_page', 'p_validated', $rows[$zone]);
    */

    // This uses more memory than the above, but is needed as pages may not have got into the database yet...

    static $cache = array();
    if (isset($cache[$zone][$include_zone])) {
        return $cache[$zone][$include_zone];
    }

    disable_php_memory_limit();

    $pages = find_all_pages_wrap($zone, false, /*$consider_redirects = */true, /*$show_method = */0, /*$page_type = */'comcode');

    static $rows = array();
    if (!isset($rows[$zone])) {
        $rows[$zone] = $GLOBALS['SITE_DB']->query_select('comcode_pages', array('the_page', 'p_validated', 'p_parent_page'), array('the_zone' => $zone));
    }
    $non_root = array();
    $root = array();
    foreach ($rows[$zone] as $row) {
        if (($row['p_parent_page'] == '') && (isset($pages[$row['the_page']]))) {
            $root[$row['the_page']] = $row['p_validated'];
        } else {
            $non_root[$row['the_page']] = $row['p_validated'];
        }
    }

    foreach ($pages as $page => $page_type) {
        if (isset($non_root[$page])) {
            unset($pages[$page]);
        }
    }

    $page_links = array();
    foreach ($pages as $page => $page_type) {
        if (is_integer($page)) {
            $page = strval($page);
        }

        if ($include_zone) {
            $key = $zone . ':' . $page;
        } else {
            $key = $page;
        }

        $page_links[$key] = isset($root[$page]) ? $root[$page] : 1;
    }
    $cache[$zone][$include_zone] = $page_links;
    return $page_links;
}

/**
 * Get an HTML selection list for some part of the Sitemap.
 *
 * @param  ID_TEXT $root_page_link The page-link we are starting from.
 * @param  boolean $under_only Create from under this node, rather than at it.
 * @param  ?ID_TEXT $default Default selection (null: none).
 * @param  ?array $valid_node_types List of node types we will return/recurse-through (null: no limit)
 * @param  ?array $valid_selectable_content_types List of node types we will allow to be selectable (null: no limit)
 * @param  integer $check_permissions_against Check permissions according to this bitmask of possibilities (requiring all in the bitmask to be matched)
 * @param  ?MEMBER $check_permissions_for The member we are checking permissions for (null: current member)
 * @param  boolean $consider_validation Whether to filter out non-validated entries if the $check_permissions_for user doesn't have the privilege to see them AND doesn't own them
 * @param  ?MEMBER $only_owned The member we are only finding owned content of (null: no such limit); nodes leading up to owned content will be shown, but not as selectable
 * @param  boolean $use_compound_list Whether to produce selection IDs as a comma-separated list of all selectable sub-nodes.
 * @param  ?mixed $filter_func Filter function for limiting what rows will be included (null: none).
 * @return Tempcode List.
 */
function create_selection_list($root_page_link, $under_only = false, $default = null, $valid_node_types = null, $valid_selectable_content_types = null, $check_permissions_against = 0, $check_permissions_for = null, $consider_validation = false, $only_owned = null, $use_compound_list = false, $filter_func = null)
{
    if (is_null($check_permissions_for)) {
        $check_permissions_for = get_member();
    }

    $options = SITEMAP_GEN_NONE;
    $options |= SITEMAP_GEN_CHECK_PERMS;
    if ($consider_validation) {
        $options |= SITEMAP_GEN_CONSIDER_VALIDATION;
    }

    $out = new Tempcode();
    $root_node = retrieve_sitemap_node($root_page_link, null, null, null, null, $options, '_SEARCH', is_null($filter_func) ? 0 : SITEMAP_GATHER_DB_ROW);

    if (!$under_only) {
        _create_selection_list($out, $root_node, $default, $valid_selectable_content_types, $check_permissions_against, $check_permissions_for, $only_owned, $use_compound_list, $filter_func);
    } else {
        if (isset($root_node['children'])) {
            foreach ($root_node['children'] as $child_node) {
                _create_selection_list($out, $child_node, $default, $valid_selectable_content_types, $check_permissions_against, $check_permissions_for, $only_owned, $use_compound_list, $filter_func);
            }
        }
    }

    return $out;
}

/**
 * Recurse function for create_selection_list.
 *
 * @param  Tempcode $out Output Tempcode.
 * @param  array $node Node being recursed.
 * @param  ?ID_TEXT $default Default selection (null: none).
 * @param  ?array $valid_selectable_content_types List of node types we will allow to be selectable (null: no limit)
 * @param  integer $check_permissions_against Check permissions according to this bitmask of possibilities (requiring all in the bitmask to be matched)
 * @param  ?MEMBER $check_permissions_for The member we are checking permissions for (null: current member)
 * @param  ?MEMBER $only_owned The member we are only finding owned content of (null: no such limit); nodes leading up to owned content will be shown, but not as selectable
 * @param  boolean $use_compound_list Whether to produce selection IDs as a comma-separated list of all selectable sub-nodes.
 * @param  ?mixed $filter_func Filter function for limiting what rows will be included (null: none).
 * @param  integer $depth Recursion depth.
 * @return string Compound list.
 *
 * @ignore
 */
function _create_selection_list(&$out, $node, $default, $valid_selectable_content_types, $check_permissions_against, $check_permissions_for, $only_owned, $use_compound_list, $filter_func, $depth = 0)
{
    // Skip?
    if (!is_null($check_permissions_for)) {
        foreach ($node['permissions'] as $permission) {
            if ($permission['type'] == 'privilege') {
                if (($check_permissions_against & CSL_PERMISSION_ADD) != 0) {
                    if (preg_match('#^submit_#', $permission['privilege']) != 0) {
                        if (!has_privilege($check_permissions_for, $permission['privilege'], $permission['page_name'], array($permission['permission_module'], $permission['category_name']))) {
                            return '';
                        }
                    }
                }
                if (($check_permissions_against & CSL_PERMISSION_EDIT) != 0) {
                    if (preg_match('#^edit_#', $permission['privilege']) != 0) {
                        if (!has_privilege($check_permissions_for, $permission['privilege'], $permission['page_name'], array($permission['permission_module'], $permission['category_name']))) {
                            return '';
                        }
                    }
                }
                if (($check_permissions_against & CSL_PERMISSION_DELETE) != 0) {
                    if (preg_match('#^delete_#', $permission['privilege']) != 0) {
                        if (!has_privilege($check_permissions_for, $permission['privilege'], $permission['page_name'], array($permission['permission_module'], $permission['category_name']))) {
                            return '';
                        }
                    }
                }
            }
        }
    }
    if (!is_null($only_owned)) {
        if ($node['submitter'] != $only_owned) {
            return '';
        }
    }
    if (!is_null($filter_func)) {
        if (!call_user_func($filter_func, $node)) {
            return '';
        }
    }

    $content_id = $node['content_id'];
    if (is_null($content_id)) {
        $content_id = $node['page_link'];
    }
    if (is_null($content_id)) {
        $content_id = '';
    }

    // Recurse, working out $children and $compound_list
    $children = new Tempcode();
    $child_compound_list = '';
    if (isset($node['children'])) {
        foreach ($node['children'] as $child_node) {
            $_child_compound_list = _create_selection_list($children, $child_node, $default, $valid_selectable_content_types, $check_permissions_against, $check_permissions_for, $only_owned, $use_compound_list, $filter_func, $depth + 1);
            if ($_child_compound_list != '') {
                $child_compound_list .= ($child_compound_list != '') ? (',' . $_child_compound_list) : $_child_compound_list;
            }
        }
    }
    $compound_list = $content_id . (($child_compound_list != '') ? (',' . $child_compound_list) : '');

    // Handle node
    $title = str_repeat('-', $depth) . $node['title']->evaluate();
    $selected = ($content_id === (is_integer($default) ? strval($default) : $default));
    $disabled = (!is_null($valid_selectable_content_types) && !in_array($node['content_type'], $valid_selectable_content_types));
    $_content_id = $use_compound_list ? $compound_list : $content_id;
    $out->attach(form_input_list_entry($_content_id, $selected, $title, false, $disabled));

    // Attach recursion result
    $out->attach($children);

    return $compound_list;
}
