<?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    commandr
 */

/**
 * Resource-fs base class.
 *
 * @package    commandr
 */
abstract class Resource_fs_base
{
    /*
    FINDING INFORMATION ABOUT HOOK STRUCTURE
    */

    public $folder_resource_type = null;
    public $file_resource_type = null;
    public $_cma_object = array();

    /**
     * Get the file resource info for this Commandr-fs resource hook.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @return array The info
     */
    protected function _get_cma_info($resource_type)
    {
        if (!array_key_exists($resource_type, $this->_cma_object)) {
            require_code('content');
            $this->_cma_object[$resource_type] = get_content_object($resource_type);
        }
        return $this->_cma_object[$resource_type]->info();
    }

    /**
     * Find whether a resource type is of a folder-type.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @return boolean Whether it is
     */
    public function is_folder_type($resource_type)
    {
        $folder_types = is_array($this->folder_resource_type) ? $this->folder_resource_type : (is_null($this->folder_resource_type) ? array() : array($this->folder_resource_type));
        return in_array($resource_type, $folder_types);
    }

    /**
     * Find whether a resource type is of a file-type.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @return boolean Whether it is
     */
    public function is_file_type($resource_type)
    {
        $file_types = is_array($this->file_resource_type) ? $this->file_resource_type : (is_null($this->file_resource_type) ? array() : array($this->file_resource_type));
        return in_array($resource_type, $file_types);
    }

    /*
    HOOKS MAY OVERRIDE THESE AS REQUIRED, TO ENCODE IMPLEMENTATION COMPLEXITIES
    */

    /**
     * Whether the filesystem hook is active.
     *
     * @return boolean Whether it is
     */
    protected function _is_active()
    {
        return true;
    }

    /**
     * Whether the filesystem hook can handle a particular file type.
     *
     * @param  string $filetype The file type (no file extension)
     * @return array List of our resource types that can
     */
    public function can_accept_filetype($filetype)
    {
        if ($filetype != RESOURCE_FS_DEFAULT_EXTENSION) {
            return array();
        }

        $ret = array();
        if (!is_null($this->folder_resource_type)) {
            $ret = array_merge($ret, is_array($this->folder_resource_type) ? $this->folder_resource_type : array($this->folder_resource_type));
        }
        if (!is_null($this->file_resource_type)) {
            $ret = array_merge($ret, is_array($this->file_resource_type) ? $this->file_resource_type : array($this->file_resource_type));
        }
        return $ret;
    }

    /**
     * Find whether a kind of resource handled by this hook (folder or file) can be under a particular kind of folder.
     *
     * @param  ?ID_TEXT $above Folder resource type (null: root)
     * @param  ID_TEXT $under Resource type (may be file or folder)
     * @return ?array A map: The parent referencing field, the table it is in, and the ID field of that table (null: cannot be under)
     */
    protected function _has_parent_child_relationship($above, $under)
    {
        $sub_info = $this->_get_cma_info($under);
        if ($sub_info === null) {
            return null;
        }

        $is_file = $this->is_file_type($under);

        if ($is_file) {
            // If no folder types, files are top level
            if ((is_null($this->folder_resource_type)) && (is_null($above))) {
                return array(
                    'cat_field' => null,
                    'linker_table' => null,
                    'id_field' => $sub_info['id_field'],
                    'id_field_linker' => $sub_info['id_field'],
                    'cat_field_numeric' => null,
                );
            }

            // If there are folder types, files can not be top level
            if ((!is_null($this->folder_resource_type)) && (is_null($above))) {
                return null;
            }
        }

        if (array_key_exists('parent_category_field__resource_fs', $sub_info)) {
            $sub_info['parent_category_field'] = $sub_info['parent_category_field__resource_fs'];
        }
        if (!$is_file) {
            if ((is_null($sub_info['parent_category_field'])) && (!is_null($sub_info['parent_spec__field_name']))) { // Some fiddling, as we are smart enough to detect need for linker table
                $sub_info['parent_category_field'] = $sub_info['parent_spec__parent_name'];
            }
        }

        // If there is no category for $under, then it can only be top-level
        if ((!array_key_exists('parent_category_field', $sub_info)) || (is_null($sub_info['parent_category_field']))) {
            if (!is_null($above)) {
                return null;
            }
        }

        $folder_info = is_null($above) ? $sub_info : $this->_get_cma_info($above);
        if ($folder_info === null) {
            return null;
        }
        return array(
            'cat_field' => $sub_info['parent_category_field'],
            'linker_table' => $is_file ? null : $sub_info['parent_spec__table_name'],
            'id_field' => $sub_info['id_field'],
            'id_field_linker' => $is_file ? null : $sub_info['parent_spec__field_name'],
            'cat_field_numeric' => $folder_info['id_field_numeric'],
        );
    }

    /**
     * Load function for resource-fs (for files). Finds the data for some resource from a resource-fs file.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @return ~string Resource data (false: error)
     */
    public function file_load__flat($filename, $path)
    {
        if (array() == $this->can_accept_filetype(get_file_extension($filename))) {
            return false;
        }
        return $this->file_load_json($filename, $path); // By default, only defer to the inbuilt Composr JSON implementation (hooks may override this with support for other kinds of interchange file formats)
    }

    /**
     * Load function for resource-fs (for folders). Finds the data for some resource from a resource-fs folder.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @return ~string Resource data (false: error)
     */
    public function folder_load__flat($filename, $path)
    {
        $ext = get_file_extension($filename);
        if ($ext != '') {
            if (array() == $this->can_accept_filetype($ext)) {
                return false;
            }
        }
        return $this->folder_load_json($filename, $path); // By default, only defer to the inbuilt Composr JSON implementation (hooks may override this with support for other kinds of interchange file formats)
    }

    /**
     * Save function for resource-fs (for files). Parses the data for some resource to a resource-fs file.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  string $data Resource data
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function file_save__flat($filename, $path, $data)
    {
        // Files other stuff makes, we don't want auto-created junk files creating composr content
        $all_disallowed = array(
            '__macosx',
            'thumbs.db:encryptable',
            'thumbs.db',
            '.ds_store',
        );
        foreach ($all_disallowed as $disallowed) {
            if (strtolower($filename) == $disallowed) {
                return false;
            }
        }
        if (substr($filename, 0, 1) == '.') {
            return false;
        }

        if (array() == $this->can_accept_filetype(get_file_extension($filename))) {
            return false;
        }
        return $this->file_save_json($filename, $path, $data); // By default, only defer to the inbuilt Composr JSON implementation (hooks may override this with support for other kinds of interchange file formats)
    }

    /**
     * Save function for resource-fs (for folders). Parses the data for some resource to a resource-fs folder.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  string $data Resource data
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function folder_save__flat($filename, $path, $data)
    {
        $ext = get_file_extension($filename);
        if ($ext != '') {
            if (array() == $this->can_accept_filetype($ext)) {
                return false;
            }
        }
        return $this->folder_save_json($filename, $path, $data); // By default, only defer to the inbuilt Composr JSON implementation (hooks may override this with support for other kinds of interchange file formats)
    }

    /**
     * Reinterpret the input of a file, into a way we can understand it to add/edit. Hooks may override this with special import code.
     *
     * @param  LONG_TEXT $filename Filename OR Resource label
     * @param  string $path The path (blank: root / not applicable)
     * @param  array $properties Properties
     * @param  ID_TEXT $resource_type The resource type
     * @return array A pair: the resource label, Properties (may be empty, properties given are open to interpretation by the hook but generally correspond to database fields)
     */
    protected function _file_magic_filter($filename, $path, $properties, $resource_type)
    {
        $label = basename($filename, '.' . RESOURCE_FS_DEFAULT_EXTENSION); // Default implementation is simply to assume the stub of the filename (or may be a raw label already, with no file type) is the resource label
        if (array_key_exists('label', $properties)) {
            $label = $properties['label']; // ...unless the label was explicitly given
        }

        $this->_resource_save_extend_pre($properties, $resource_type, $filename, $label);

        return array($properties, $label); // Leave properties alone
    }

    /**
     * Reinterpret the input of a folder, into a way we can understand it to add/edit. Hooks may override this with special import code.
     *
     * @param  LONG_TEXT $filename Filename OR Resource label
     * @param  string $path The path (blank: root / not applicable)
     * @param  array $properties Properties
     * @return array A pair: the resource label, Properties (may be empty, properties given are open to interpretation by the hook but generally correspond to database fields)
     */
    protected function _folder_magic_filter($filename, $path, $properties)
    {
        return array($properties, $filename); // Default implementation is simply to assume the filename is the resource label, and leave properties alone
    }

    /**
     * Get the filename for a resource ID. Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @return ?ID_TEXT The filename (null: could not find)
     */
    public function file_convert_id_to_filename($resource_type, $resource_id)
    {
        $moniker = find_moniker_via_id($resource_type, $resource_id);
        if (is_null($moniker)) {
            return null;
        }
        return $moniker . '.' . RESOURCE_FS_DEFAULT_EXTENSION;
    }

    /**
     * Get the filename for a resource ID. Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @return ?ID_TEXT The filename (null: could not find)
     */
    public function folder_convert_id_to_filename($resource_type, $resource_id)
    {
        return find_moniker_via_id($resource_type, $resource_id);
    }

    /**
     * Get the resource ID for a filename (of file). Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $filename The filename, or filepath
     * @param  ?ID_TEXT $resource_type The resource type (null: assumption of only one folder resource type for this hook; only passed as non-null from overridden functions within hooks that are calling this as a helper function)
     * @return ?array A pair: The resource type, the resource ID (null: could not find)
     */
    public function file_convert_filename_to_id($filename, $resource_type = null)
    {
        if (is_null($resource_type)) {
            $resource_type = $this->file_resource_type;
        }

        $filename = preg_replace('#^.*/#', '', $filename); // Paths not needed, as filenames are globally unique; paths would not be in alternative_ids table

        $moniker = basename($filename, '.' . RESOURCE_FS_DEFAULT_EXTENSION); // Remove file extension from filename
        $resource_id = find_id_via_moniker($resource_type, $moniker);
        if (is_null($resource_id)) {
            $resource_id = find_id_via_label($resource_type, $moniker);
        }
        return array($resource_type, $resource_id);
    }

    /**
     * Get the resource ID for a filename (of folder). Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $filename The filename, or filepath
     * @param  ?ID_TEXT $resource_type The resource type (null: assumption of only one folder resource type for this hook; only passed as non-null from overridden functions within hooks that are calling this as a helper function)
     * @return array A pair: The resource type, the resource ID
     */
    public function folder_convert_filename_to_id($filename, $resource_type = null)
    {
        if (is_null($resource_type)) {
            $resource_type = $this->folder_resource_type;
        }

        $moniker = preg_replace('#^.*/#', '', $filename); // Paths not needed, as filenames are globally unique; paths would not be in alternative_ids table

        $resource_id = find_id_via_moniker($resource_type, $moniker);
        return array($resource_type, $resource_id);
    }

    /*
    JUGGLING PROPERTIES
    */

    /**
     * Find a default property, defaulting to blank.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?string The value (null: null value)
     */
    protected function _default_property_str($properties, $property)
    {
        return array_key_exists($property, $properties) ? $properties[$property] : '';
    }

    /**
     * Find a default property, defaulting to null.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?string The value (null: null value)
     */
    protected function _default_property_str_null($properties, $property)
    {
        return array_key_exists($property, $properties) ? $properties[$property] : null;
    }

    /**
     * Find an integer default property, defaulting to 0.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_int($properties, $property)
    {
        if (!array_key_exists($property, $properties)) {
            return 0;
        }
        if (is_null($properties[$property])) {
            return 0;
        }
        if (is_integer($properties[$property])) {
            return $properties[$property];
        }
        return intval($properties[$property]);
    }

    /**
     * Find a default property, defaulting to null.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_int_null($properties, $property)
    {
        if (!array_key_exists($property, $properties)) {
            return null;
        }
        if (is_null($properties[$property])) {
            return null;
        }
        if (is_integer($properties[$property])) {
            return $properties[$property];
        }
        return intval($properties[$property]);
    }

    /**
     * Convert a category to an integer, defaulting to null if it is blank.
     *
     * @param  ?ID_TEXT $category The category value (blank: root) (null: root)
     * @return ?integer The category (null: root)
     */
    protected function _integer_category($category)
    {
        if (is_null($category)) {
            return null;
        }
        return ($category == '') ? null : intval($category);
    }

    /**
     * Find a default property, defaulting to the average of what is there already, or the given default if really necessary.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @param  ID_TEXT $table The table to average within
     * @param  integer $default The last-resort default
     * @param  ?ID_TEXT $db_property The database property (null: same as $property)
     * @return integer The value
     */
    protected function _default_property_int_modeavg($properties, $property, $table, $default, $db_property = null)
    {
        if (is_null($db_property)) {
            $db_property = $property;
        }

        if (array_key_exists($property, $properties)) {
            if (is_integer($properties[$property])) {
                return $properties[$property];
            }
            return intval($properties[$property]);
        }

        static $cache = array();
        if (isset($cache[$property][$table][$default][$db_property])) {
            return $cache[$property][$table][$default][$db_property];
        }

        $db = $GLOBALS[(substr($table, 0, 2) == 'f_') ? 'FORUM_DB' : 'SITE_DB'];
        $val = $db->query_value_if_there('SELECT ' . $db_property . ',count(' . $db_property . ') AS qty FROM ' . get_table_prefix() . $table . ' GROUP BY ' . $db_property . ' ORDER BY qty DESC', false, true); // We need the mode here, not the mean
        $ret = $default;
        if (!is_null($val)) {
            $ret = $val;
        }

        $cache[$property][$table][$default][$db_property] = $ret;

        return $ret;
    }

    /**
     * Find a default property for a timestamp, defaulting to null.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_time_null($properties, $property)
    {
        if (!isset($properties[$property])) {
            return null;
        }

        return $this->_default_property_time($properties, $property);
    }

    /**
     * Find a default property for a timestamp, defaulting to current time.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return integer The value
     */
    protected function _default_property_time($properties, $property)
    {
        if (!isset($properties[$property])) {
            return time();
        }

        if (is_integer($properties[$property])) {
            return $properties[$property];
        }

        return remap_portable_as_time($properties[$property]);
    }

    /**
     * Find a default property for a member, defaulting to null.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_member_null($properties, $property)
    {
        if (!isset($properties[$property])) {
            return null;
        }

        return $this->_default_property_member($properties, $property);
    }

    /**
     * Find a default property for a member.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_member($properties, $property)
    {
        if (!isset($properties[$property])) {
            return get_member();
        }

        if (is_integer($properties[$property])) {
            return $properties[$property];
        }

        $test = remap_portable_as_resource_id('member', $properties[$property]);
        if (is_null($test)) {
            return $test;
        }
        return intval($test);
    }

    /**
     * Find a default property for a usergroup, defaulting to null.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_group_null($properties, $property)
    {
        if (!isset($properties[$property])) {
            return null;
        }

        return $this->_default_property_group($properties, $property);
    }

    /**
     * Find a default property for a usergroup.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?integer The value (null: null value)
     */
    protected function _default_property_group($properties, $property)
    {
        if (!isset($properties[$property])) {
            $properties[$property] = db_get_first_id();
        }

        if (is_integer($properties[$property])) {
            return $properties[$property];
        }

        $test = remap_portable_as_resource_id('group', $properties[$property]);
        if (is_null($test)) {
            return $test;
        }
        return intval($test);
    }

    /**
     * Find a default property for a URL.
     *
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @param  boolean $ignore_conflicts Whether to ignore conflicts with existing files (=edit op, basically)
     * @return string The value
     */
    protected function _default_property_urlpath($properties, $property, $ignore_conflicts = false)
    {
        if (empty($properties[$property])) {
            return '';
        }

        return remap_portable_as_urlpath($properties[$property], $ignore_conflicts);
    }

    /**
     * Find a default property for a foreign key, defaulting to null.
     *
     * @param  array $_table_referenced The table the key is to
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?mixed The value (null: null value)
     */
    protected function _default_property_foreign_key_null($_table_referenced, $properties, $property)
    {
        if (!isset($properties[$property])) {
            return null;
        }

        return $this->_default_property_foreign_key($_table_referenced, $properties, $property);
    }

    /**
     * Find a default property for a foreign key.
     *
     * @param  array $_table_referenced The table the key is to
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return mixed The value
     */
    protected function _default_property_foreign_key($_table_referenced, $properties, $property)
    {
        return remap_portable_as_foreign_key($_table_referenced, $properties[$property]);
    }

    /**
     * Find a default property for a resource, defaulting to null.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return ?mixed The value (null: null value)
     */
    protected function _default_property_resource_id_null($resource_type, $properties, $property)
    {
        if (!isset($properties[$property])) {
            return null;
        }

        return $this->_default_property_resource_id($resource_type, $properties, $property);
    }

    /**
     * Find a default property for a resource.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  array $properties The properties
     * @param  ID_TEXT $property The property
     * @return mixed The value
     */
    protected function _default_property_resource_id($resource_type, $properties, $property)
    {
        return remap_portable_as_resource_id($resource_type, $properties[$property]);
    }

    /**
     * Turn a label into a name.
     *
     * @param  LONG_TEXT $label The label
     * @return ID_TEXT The name
     */
    protected function _create_name_from_label($label)
    {
        $name = strtolower($label);
        $name = preg_replace('#[^\w\.\-]#', '_', $name);
        $name = preg_replace('#\_+\$#', '', $name);
        if ($name == '') {
            $name = 'unnamed';
        }
        require_code('urls2');
        $max_moniker_length = min(80, intval(get_option('max_moniker_length')));
        return substr($name, 0, $max_moniker_length);
    }

    /**
     * Helper function: detect if a resource did not save all the properties it was given.
     *
     * @param  ?ID_TEXT $resource_type The resource type (null: unknown)
     * @param  ~ID_TEXT                 $resource_id The resource ID (false: was not added/edited)
     * @param  string $path The path (blank: root / not applicable)
     * @param  array $properties Properties
     */
    protected function _log_if_save_matchup($resource_type, $resource_id, $path, $properties)
    {
        if ($resource_type === null) {
            return; // Too difficult to check, don't bother; only expert coding would lead to this scenario anyway
        }
        if ($resource_id === false) {
            return;
        }

        global $RESOURCE_FS_LOGGER;
        if ($RESOURCE_FS_LOGGER === null) {
            return; // Too much unnecessarily work if the logger is not on
        }

        $ok = true;

        static $similar_ok_before = array();
        if ((isset($similar_ok_before[$resource_type][$path])) && ($similar_ok_before[$resource_type][$path] > 10)) {
            return;
        }

        $found_filename = $this->convert_id_to_filename($resource_type, $resource_id);
        $found_path = $this->search($resource_type, $resource_id, true);
        if ($found_path !== $path) {
            resource_fs_logging('Path mismatch for what was saved (actual ' . $found_path . ' vs intended ' . $path . ')', 'warn');
            $ok = false;
        }

        $actual_properties = $this->resource_load($resource_type, $found_filename, $found_path);
        foreach (array_keys($properties) as $p) {
            if (array_key_exists($p, $actual_properties)) {
                if (str_replace(do_lang('NA'), '', @strval($actual_properties[$p])) != str_replace(do_lang('NA'), '', @strval($properties[$p]))) {
                    resource_fs_logging('Property (' . $p . ') value mismatch for ' . $found_filename . ' (actual ' . str_replace(do_lang('NA'), '', @strval($actual_properties[$p])) . ' vs intended ' . str_replace(do_lang('NA'), '', @strval($properties[$p])) . ').', 'warn');
                    $ok = false;
                }
            } else {
                resource_fs_logging('Property (' . $p . ') not applicable for ' . $found_filename . '.', 'warn');
                $ok = false;
            }
        }

        if ($ok) {
            if (!isset($similar_ok_before[$resource_type][$path])) {
                $similar_ok_before[$resource_type][$path] = 0;
            }
            $similar_ok_before[$resource_type][$path]++;
        }
    }

    /*
    ABSTRACT/AGNOSTIC RESOURCE-FS API FOR INTERNAL COMPOSR USE
    */

    /**
     * Find the foldername/subpath to a resource.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @param  boolean $full_subpath Whether to include the full subpath
     * @return ?string The foldername/subpath (null: not found)
     */
    public function search($resource_type, $resource_id, $full_subpath = false)
    {
        // Find resource
        require_code('content');
        list(, , $cma_info, $content_row) = content_get_details($resource_type, $resource_id, true);
        if (is_null($content_row)) {
            return null;
        }

        // Okay, exists, but what if no categories for this?
        if (is_null($this->folder_resource_type)) {
            return '';
        }

        // For each folder type, see if we can find a position for this resource
        $cat_resource_types = is_array($this->folder_resource_type) ? $this->folder_resource_type : array($this->folder_resource_type);
        $cat_resource_types = array_reverse($cat_resource_types); // Need to look from deepest outward, i.e. maximum specificity first
        $cat_resource_types[] = null;
        foreach ($cat_resource_types as $cat_resource_type) {
            $relationship = $this->_has_parent_child_relationship($cat_resource_type, $resource_type);
            if (is_null($relationship)) {
                continue;
            }

            if (is_null($cat_resource_type)) {
                return ''; // Exists in root
            }

            // Do we need to load up a linker table for getting the category?
            if ((!is_null($relationship['linker_table'])) && ($cma_info['table'] != $relationship['linker_table'])) {
                $where = array($relationship['id_field_linker'] => $content_row[$cma_info['id_field']]);
                $categories = $cma_info['connection']->query_select($relationship['linker_table'], array($relationship['cat_field']), $where);
            } else {
                $categories = array($content_row);
            }

            foreach ($categories as $category) {
                // Find category
                $_category_id = $category[$relationship['cat_field']];
                $category_id = is_string($_category_id) ? $_category_id : (is_null($_category_id) ? '' : strval($_category_id));

                // Convert category to path
                $subpath = $this->folder_convert_id_to_filename($cat_resource_type, $category_id);
                if (is_null($subpath)) {
                    continue; // Weird, some kind of broken category. We'll have to say we cannot find, as it won't be linked into the folder tree.
                }

                // Full subpath requested?
                if ($full_subpath) {
                    $above_subpath = $this->search($cat_resource_type, $category_id, $full_subpath);
                    if ($above_subpath != '') {
                        $subpath = $above_subpath . '/' . $subpath;
                    }
                }

                return $subpath;
            }
        }

        return null;
    }

    /**
     * Convert a label to a filename, possibly with auto-creating if needed. This is useful for the Composr-side resource-agnostic API.
     *
     * @param  LONG_TEXT $label Resource label
     * @param  string $subpath The path (blank: root / not applicable). It may end in "/*" if you want to look for a match under a certain directory
     * @param  ID_TEXT $resource_type Resource type
     * @param  boolean $must_already_exist Whether the content must already exist
     * @param  ?ID_TEXT $use_guid_for_new GUID to auto-create with (null: either not auto-creating, or not specifying the GUID if we are)
     * @return ?ID_TEXT The filename (null: not found)
     */
    public function convert_label_to_filename($label, $subpath, $resource_type, $must_already_exist = false, $use_guid_for_new = null)
    {
        $label = cms_mb_substr($label, 0, 255);
        $resource_id = $this->convert_label_to_id($label, $subpath, $resource_type, $must_already_exist, $use_guid_for_new);
        if (is_null($resource_id)) {
            return null;
        }
        return find_commandr_fs_filename_via_id($resource_type, $resource_id);
    }

    /**
     * Convert a label to an ID, possibly with auto-creating if needed. This is useful for the Composr-side resource-agnostic API.
     *
     * @param  SHORT_TEXT $_label Resource label
     * @param  string $subpath The path (blank: root / not applicable). It may end in "/*" if you want to look for a match under a certain directory
     * @param  ID_TEXT $resource_type Resource type
     * @param  boolean $must_already_exist Whether the content must already exist
     * @param  ?ID_TEXT $use_guid_for_new GUID to auto-create with (null: either not auto-creating, or not specifying the GUID if we are)
     * @return ?ID_TEXT The ID (null: not found)
     */
    public function convert_label_to_id($_label, $subpath, $resource_type, $must_already_exist = false, $use_guid_for_new = null)
    {
        $label = cms_mb_substr($_label, 0, 255);

        $resource_id = find_id_via_label($resource_type, $label, $subpath);
        if (is_null($resource_id)) {
            if (!$must_already_exist) {
                // Not found, create...
                resource_fs_logging('Auto-creating an unmatched ' . $resource_type . ' label reference, "' . $_label . '", under "' . $subpath . '"', 'notice');

                // Create subpath
                if ($subpath != '') {
                    if (substr($subpath, -2) == '/*') {
                        $subpath = substr($subpath, 0, strlen($subpath) - 2);
                    }

                    $subpath_bits = explode('/', $subpath);
                    $subpath_above = '';
                    foreach ($subpath_bits as $i => $subpath_bit) {
                        if (is_array($this->folder_resource_type)) {
                            $folder_resource_type = $this->folder_resource_type[array_key_exists($i, $this->folder_resource_type) ? $i : (count($this->folder_resource_type) - 1)];
                        } else {
                            $folder_resource_type = $this->folder_resource_type;
                        }

                        list(, $subpath_id) = $this->folder_convert_filename_to_id($subpath_bit);
                        if (is_null($subpath_id)) { // Missing, find via moniker that doesn't match a label due to prefixing
                            if (preg_match('#^[A-Z]+-#', $subpath_bit) != 0) {
                                $_subpath_bit = preg_replace('#^[A-Z]+-#', '', $subpath_bit);
                                $detected_resource_type = strtolower(preg_replace('#-.*$#', '', $subpath_bit));
                                $subpath_id = find_id_via_label($detected_resource_type, $_subpath_bit, $subpath_above);
                            }
                        }
                        if (is_null($subpath_id)) { // Missing, find via monikerised label
                            $_subpath_bit = $this->_create_name_from_label($subpath_bit);
                            list(, $subpath_id) = $this->folder_convert_filename_to_id($_subpath_bit);
                        }
                        if (is_null($subpath_id)) { // Missing, find via label
                            $subpath_id = find_id_via_label($folder_resource_type, $subpath_bit, $subpath_above);
                        }
                        if (is_null($subpath_id)) { // Still missing, create folder
                            $subpath_id = $this->folder_add($subpath_bit, $subpath_above, array());
                        }

                        if ($subpath_above != '') {
                            $subpath_above .= '/';
                        }
                        $subpath_above .= $this->folder_convert_id_to_filename($folder_resource_type, $subpath_id);
                    }
                }

                // Create main resource
                $resource_id = $this->resource_add($resource_type, is_null($_label) ? uniqid('arbitrary', true) : $_label, $subpath, array());
                if ($resource_id === false) {
                    return null;
                }
                if (!is_null($use_guid_for_new)) {
                    generate_resource_fs_moniker($resource_type, $resource_id, $label, $use_guid_for_new);
                }
            }
        }
        return $resource_id;
    }

    /**
     * Get the filename for a resource ID (of file or folder). Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @return ?ID_TEXT The filename (null: not found)
     */
    public function convert_id_to_filename($resource_type, $resource_id)
    {
        if ($this->is_file_type($resource_type)) {
            return $this->file_convert_id_to_filename($resource_type, $resource_id);
        }
        if ($this->is_folder_type($resource_type)) {
            return $this->folder_convert_id_to_filename($resource_type, $resource_id);
        }
        return null;
    }

    /**
     * Get the resource ID for a filename (of file or folder). Note that filenames are unique across all folders in a filesystem.
     *
     * @param  ID_TEXT $filename The filename, or filepath
     * @param  ID_TEXT $resource_type The resource type
     * @return ?array A pair: The resource type, the resource ID (null: could not find)
     */
    public function convert_filename_to_id($filename, $resource_type)
    {
        if ($this->is_file_type($resource_type)) {
            return $this->file_convert_filename_to_id($filename, $resource_type);
        }
        if ($this->is_folder_type($resource_type)) {
            return $this->folder_convert_filename_to_id($filename, $resource_type);
        }
        return null;
    }

    /**
     * Save function for resource-fs. Parses the data for some resource to a resource-fs JSON file. Wraps file_save/folder_save.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $label Filename OR Resource label
     * @param  string $path The path (blank: root / not applicable)
     * @param  ?array $properties Properties (null: none)
     * @param  ?ID_TEXT $search_label_as Whether to look for existing records using $filename as a label and this resource type (null: $filename is a strict file name)
     * @param  ?ID_TEXT $search_path Search path (null: the same as the path saving at)
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function resource_save($resource_type, $label, $path, $properties = null, $search_label_as = null, $search_path = null)
    {
        if (is_null($properties)) {
            $properties = array();
        }

        if ($this->is_folder_type($resource_type)) {
            $resource_id = $this->folder_save($label, $path, $properties, $search_label_as, $search_path);
        } else {
            $resource_id = $this->file_save($label, $path, $properties, $search_label_as, $search_path);
        }
        return $resource_id;
    }

    /**
     * Adds some resource with the given label and properties. Wraps file_add/folder_add.
     *
     * @param  ID_TEXT $resource_type Resource type
     * @param  LONG_TEXT $label Filename OR Resource label
     * @param  string $path The path (blank: root / not applicable)
     * @param  ?array $properties Properties (may be empty, properties given are open to interpretation by the hook but generally correspond to database fields) (null: none)
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function resource_add($resource_type, $label, $path, $properties = null)
    {
        if (is_null($properties)) {
            $properties = array();
        }

        if ($this->is_folder_type($resource_type)) {
            $resource_id = $this->folder_add($label, $path, $properties, $resource_type);
            $this->_log_if_save_matchup($resource_type, $resource_id, $path, $properties);
        } else {
            $resource_id = $this->file_add($label, $path, $properties, $resource_type);
            $this->_log_if_save_matchup($resource_type, $resource_id, $path, $properties);
        }
        return $resource_id;
    }

    /**
     * Finds the properties for some resource. Wraps file_load/folder_load.
     *
     * @param  ID_TEXT $resource_type Resource type
     * @param  SHORT_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @return ~array Details of the resource (false: error)
     */
    public function resource_load($resource_type, $filename, $path)
    {
        if ($this->is_folder_type($resource_type)) {
            $properties = $this->folder_load($filename, $path);
        } else {
            $properties = $this->file_load($filename, $path);
        }
        return $properties;
    }

    /**
     * Edits the resource to the given properties. Wraps file_edit/folder_edit.
     *
     * @param  ID_TEXT $resource_type Resource type
     * @param  ID_TEXT $filename The filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  array $properties Properties (may be empty, properties given are open to interpretation by the hook but generally correspond to database fields)
     * @param  boolean $explicit_move Whether we are definitely moving (as opposed to possible having it in multiple positions)
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function resource_edit($resource_type, $filename, $path, $properties, $explicit_move = false)
    {
        if ($this->is_folder_type($resource_type)) {
            $resource_id = $this->folder_edit($filename, $path, $properties, $explicit_move);
            $this->_log_if_save_matchup($resource_type, $resource_id, $path, $properties);
        } else {
            $resource_id = $this->file_edit($filename, $path, $properties, $explicit_move);
            $this->_log_if_save_matchup($resource_type, $resource_id, $path, $properties);
        }
        return $resource_id;
    }

    /**
     * Deletes the resource. Wraps file_delete/folder_delete.
     *
     * @param  ID_TEXT $resource_type Resource type
     * @param  ID_TEXT $filename The filename
     * @param  string $path The path (blank: root / not applicable)
     * @return boolean Success status
     */
    public function resource_delete($resource_type, $filename, $path)
    {
        if ($this->is_folder_type($resource_type)) {
            resource_fs_logging('Deleted the ' . $path . '/' . $filename . ' folder as requested', 'notice');

            $status = $this->folder_delete($filename, $path);
        } else {
            resource_fs_logging('Deleted the ' . $path . '/' . $filename . ' file as requested', 'notice');

            $status = $this->file_delete($filename, $path);
        }
        return $status;
    }

    /**
     * Reset resource privileges on the resource for all usergroups.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function reset_resource_access($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        switch ($resource_type) {
            case 'comcode_page':
                list($zone_name, $page_name) = explode(':', $category);
                $cma_info['connection']->query_delete('group_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name));
                $cma_info['connection']->query_delete('member_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name));
                break;

            case 'zone':
                $cma_info['connection']->query_delete('group_zone_access', array('zone_name' => $category));
                $cma_info['connection']->query_delete('member_zone_access', array('zone_name' => $category));
                break;

            default:
                $cma_info['connection']->query_delete('group_category_access', array('module_the_name' => $module, 'category_name' => $category));
                $cma_info['connection']->query_delete('member_category_access', array('module_the_name' => $module, 'category_name' => $category));
                break;
        }
    }

    /**
     * Set resource view access on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $groups A mapping from group ID to view access
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_access($filename, $groups, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        $admin_groups = $GLOBALS['FORUM_DRIVER']->get_super_admin_groups();

        // Cleanup
        foreach (array_keys($groups) as $group_id) {
            switch ($resource_type) {
                case 'comcode_page':
                    list($zone_name, $page_name) = explode(':', $category);
                    $cma_info['connection']->query_delete('group_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name, 'group_id' => $group_id));
                    break;

                case 'zone':
                    $cma_info['connection']->query_delete('group_zone_access', array('zone_name' => $category, 'group_id' => $group_id));
                    break;

                default:
                    $cma_info['connection']->query_delete('group_category_access', array('module_the_name' => $module, 'category_name' => $category, 'group_id' => $group_id));
                    break;
            }
        }

        // Insert
        foreach ($groups as $group_id => $value) {
            if (in_array($group_id, $admin_groups)) {
                continue;
            }

            if (($value == '1') || ($value == 'true')) {
                switch ($resource_type) {
                    case 'comcode_page':
                        list($zone_name, $page_name) = explode(':', $category);
                        $cma_info['connection']->query_insert('group_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name, 'group_id' => $group_id), false, true); // Race/corruption condition
                        break;

                    case 'zone':
                        $cma_info['connection']->query_insert('group_zone_access', array('zone_name' => $category, 'group_id' => $group_id), false, true); // Race/corruption condition
                        break;

                    default:
                        $cma_info['connection']->query_insert('group_category_access', array('module_the_name' => $module, 'category_name' => $category, 'group_id' => $group_id), false, true); // Race/corruption condition
                        break;
                }
            }
        }
    }

    /**
     * Get resource view access on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     * @return array A mapping from group ID to view access
     */
    public function get_resource_access($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return array();
        }
        $module = $cma_info['permissions_type_code'];

        $admin_groups = $GLOBALS['FORUM_DRIVER']->get_super_admin_groups();
        $groups = $GLOBALS['FORUM_DRIVER']->get_usergroup_list(false, true);

        $ret = array();
        foreach (array_keys($groups) as $group_id) {
            $ret[$group_id] = '0';
        }
        foreach ($admin_groups as $group_id) {
            $ret[$group_id] = '1';
        }
        switch ($resource_type) {
            case 'comcode_page':
                list($zone_name, $page_name) = explode(':', $category);
                $groups = $cma_info['connection']->query_select('group_zone_access', array('group_id'), array('zone_name' => $zone_name, 'page_name' => $page_name));
                break;

            case 'zone':
                $groups = $cma_info['connection']->query_select('group_page_access', array('group_id'), array('page_name' => $category));
                break;

            default:
                $groups = $cma_info['connection']->query_select('group_category_access', array('group_id'), array('module_the_name' => $module, 'category_name' => $category));
                break;
        }
        foreach ($groups as $group) {
            $ret[$group['group_id']] = '1';
        }
        return $ret;
    }

    /**
     * Set resource view access on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $members A mapping from member ID to view access
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_access__members($filename, $members, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        // Cleanup
        foreach (array_keys($members) as $member_id) {
            switch ($resource_type) {
                case 'comcode_page':
                    list($zone_name, $page_name) = explode(':', $category);
                    $cma_info['connection']->query_delete('member_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name, 'member_id' => $member_id, 'active_until' => null));
                    break;

                case 'zone':
                    $cma_info['connection']->query_delete('member_zone_access', array('page_name' => $category, 'member_id' => $member_id, 'active_until' => null));
                    break;

                default:
                    $cma_info['connection']->query_delete('member_category_access', array('module_the_name' => $module, 'category_name' => $category, 'member_id' => $member_id, 'active_until' => null));
                    break;
            }
        }

        // Insert
        foreach ($members as $member_id => $value) {
            if (($value == '1') || ($value == 'true')) {
                switch ($resource_type) {
                    case 'comcode_page':
                        list($zone_name, $page_name) = explode(':', $category);
                        $cma_info['connection']->query_insert('member_page_access', array('zone_name' => $zone_name, 'page_name' => $page_name, 'member_id' => $member_id, 'active_until' => null), false, true); // Race/corruption condition
                        break;

                    case 'zone':
                        $cma_info['connection']->query_insert('member_zone_access', array('page_name' => $category, 'member_id' => $member_id, 'active_until' => null), false, true); // Race/corruption condition
                        break;

                    default:
                        $cma_info['connection']->query_insert('member_category_access', array('module_the_name' => $module, 'category_name' => $category, 'member_id' => $member_id, 'active_until' => null), false, true); // Race/corruption condition
                        break;
                }
            }
        }
    }

    /**
     * Get resource view access on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     * @return array A mapping from member ID to view access
     */
    public function get_resource_access__members($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return array();
        }
        $module = $cma_info['permissions_type_code'];

        switch ($resource_type) {
            case 'comcode_page':
                list($zone_name, $page_name) = explode(':', $category);
                $members = $cma_info['connection']->query_select('member_page_access', array('member_id'), array('zone_name' => $zone_name, 'page_name' => $page_name, 'active_until' => null));
                break;

            case 'zone':
                $members = $cma_info['connection']->query_select('member_zone_access', array('member_id'), array('zone_name' => $category, 'active_until' => null));
                break;

            default:
                $members = $cma_info['connection']->query_select('member_category_access', array('member_id'), array('module_the_name' => $module, 'category_name' => $category, 'active_until' => null));
                break;
        }
        $ret = array();
        foreach ($members as $member) {
            $ret[$member['member_id']] = '1';
        }
        return $ret;
    }

    /**
     * Reset resource privileges on the resource for all usergroups.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function reset_resource_privileges($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return; // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return; // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        $cma_info['connection']->query_delete('group_privileges', array('module_the_name' => $module, 'category_name' => $category));
        $cma_info['connection']->query_delete('member_privileges', array('module_the_name' => $module, 'category_name' => $category));
    }

    /**
     * Work out what a privilege preset means for a kind of resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     * @return ?array A mapping from privilege to minimum preset level required for privilege activation (null: unworkable)
     */
    protected function _compute_privilege_preset_scheme($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return null; // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return null; // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return null;
        }
        $module = $cma_info['permissions_type_code'];

        $page = $cma_info['cms_page'];
        require_code('zones2');
        $_overridables = extract_module_functions_page(get_module_zone($page, 'modules', null, 'php', true, false), $page, array('get_privilege_overrides'));
        if (is_null($_overridables[0])) {
            $overridables = array();
        } else {
            $overridables = is_array($_overridables[0]) ? call_user_func_array($_overridables[0][0], $_overridables[0][1]) : eval($_overridables[0]);
        }

        // Work out what privileges we need to work with
        $privileges_scheme = array();
        foreach ($overridables as $override => $cat_support) {
            $usual_suspects = array('bypass_validation_.*range_content', 'edit_.*range_content', 'edit_own_.*range_content', 'delete_.*range_content', 'delete_own_.*range_content', 'submit_.*range_content');
            $access = array(2, 3, 2, 3, 2, 1); // The minimum access level that turns on each of the above permissions   NB: Also defined in permissions.js, so keep that in-sync
            foreach ($usual_suspects as $i => $privilege) {
                if (preg_match('#' . $privilege . '#', $override) != 0) {
                    $min_level = $access[$i];
                    $privileges_scheme[$override] = $min_level;
                }
            }
        }

        return $privileges_scheme;
    }

    /**
     * Set resource privileges from a preset on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $group_presets A mapping from group ID to preset value. Preset values are 0 (read only) to 3 (moderation)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_privileges_from_preset($filename, $group_presets, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        $privileges_scheme = $this->_compute_privilege_preset_scheme($filename, $resource_type, $category);
        if (is_null($privileges_scheme)) {
            return;
        }

        // Set the privileges
        $group_settings = array();
        foreach ($group_presets as $group_id => $level) {
            $group_settings[$group_id] = array();
            foreach ($privileges_scheme as $privilege => $min_level) {
                $setting = ($level < $min_level) ? '0' : '1';
                $group_settings[$group_id][$privilege] = $setting;
            }
        }
        $this->set_resource_privileges($filename, $group_settings);
    }

    /**
     * Set resource privileges on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $group_settings A map between group ID, and a map of privilege to setting
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_privileges($filename, $group_settings, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return; // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return; // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        $admin_groups = $GLOBALS['FORUM_DRIVER']->get_super_admin_groups();

        // Insert
        foreach ($group_settings as $group_id => $value) {
            if (in_array($group_id, $admin_groups)) {
                continue;
            }

            foreach ($value as $privilege => $setting) {
                if ($setting != '') {
                    $cma_info['connection']->query_delete('group_privileges', array('module_the_name' => $module, 'category_name' => $category, 'group_id' => $group_id, 'privilege' => $privilege, 'the_page' => ''));
                    $cma_info['connection']->query_insert('group_privileges', array('module_the_name' => $module, 'category_name' => $category, 'group_id' => $group_id, 'privilege' => $privilege, 'the_page' => '', 'the_value' => intval($setting)), false, true); // Race/corruption condition
                }
            }
        }
    }

    /**
     * Get the resource privileges for the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     * @return array A map between group ID, and a map of privilege to setting
     */
    public function get_resource_privileges($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return array(); // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return array(); // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return array();
        }
        $module = $cma_info['permissions_type_code'];

        $page = $cma_info['cms_page'];
        require_code('zones2');
        $_overridables = extract_module_functions_page(get_module_zone($page, 'modules', null, 'php', true, false), $page, array('get_privilege_overrides'));
        if (is_null($_overridables[0])) {
            $overridables = array();
        } else {
            $overridables = is_array($_overridables[0]) ? call_user_func_array($_overridables[0][0], $_overridables[0][1]) : eval($_overridables[0]);
        }

        $admin_groups = $GLOBALS['FORUM_DRIVER']->get_super_admin_groups();
        $groups = $GLOBALS['FORUM_DRIVER']->get_usergroup_list(false, true);

        $ret = array();
        foreach (array_keys($groups) as $group_id) {
            $ret[$group_id] = array();
            foreach ($overridables as $override => $cat_support) {
                if ($cat_support) {
                    if (in_array($group_id, $admin_groups)) {
                        $ret[$group_id][$override] = '1';
                    } else {
                        $ret[$group_id][$override] = '1';
                    }
                }
            }
        }
        $groups = $cma_info['connection']->query_select('group_privileges', array('group_id', 'privilege', 'the_value'), array('module_the_name' => $module, 'category_name' => $category, 'the_page' => ''));
        foreach ($groups as $group) {
            $ret[$group['group_id']][$group['privilege']] = strval($group['the_value']);
        }
        return $ret;
    }

    /**
     * Set resource privileges from a preset so that a member has custom privileges on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $member_presets A mapping from member ID to preset value. Preset values are 0 (read only) to 3 (moderation)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_privileges_from_preset__members($filename, $member_presets, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        $privileges_scheme = $this->_compute_privilege_preset_scheme($filename, $resource_type, $category);
        if (is_null($privileges_scheme)) {
            return;
        }

        // Set the privileges
        $member_settings = array();
        foreach ($member_presets as $member_id => $level) {
            $member_settings[$member_id] = array();
            foreach ($privileges_scheme as $privilege => $min_level) {
                $setting = ($level < $min_level) ? '0' : '1';
                $member_settings[$member_id][$privilege] = $setting;
            }
        }
        $this->set_resource_privileges__members($filename, $member_settings);
    }

    /**
     * Set a resource privilege so that a member has a custom privilege on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  array $member_settings A map between member ID, and a map of privilege to setting
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     */
    public function set_resource_privileges__members($filename, $member_settings, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return; // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return; // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $module = $cma_info['permissions_type_code'];

        foreach ($member_settings as $member_id => $value) {
            foreach ($value as $privilege => $setting) {
                if ($setting != '') {
                    $cma_info['connection']->query_delete('member_privileges', array('module_the_name' => $module, 'category_name' => $category, 'member_id' => $member_id, 'privilege' => $privilege, 'the_page' => ''));
                    $cma_info['connection']->query_insert('member_privileges', array('module_the_name' => $module, 'category_name' => $category, 'member_id' => $member_id, 'privilege' => $privilege, 'the_page' => '', 'the_value' => intval($setting), 'active_until' => null), false, true); // Race/corruption condition
                }
            }
        }
    }

    /**
     * Get the resource privileges for all members that have custom privileges on the resource.
     *
     * @param  ?ID_TEXT $filename Resource filename (assumed to be of a folder type) (null: $resource_type & $category specified instead)
     * @param  ?ID_TEXT $resource_type The resource type (null: $filename specified instead)
     * @param  ?ID_TEXT $category The resource ID (null: $filename specified instead)
     * @return array A map between member ID, and a map of privilege to setting
     */
    public function get_resource_privileges__members($filename, $resource_type = null, $category = null)
    {
        if ((is_null($filename)) && ((is_null($resource_type)) || (is_null($category)))) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        if (is_null($resource_type)) {
            list($resource_type, $category) = $this->folder_convert_filename_to_id($filename);
        }

        if ($resource_type == 'zone') {
            return array(); // Can not be done
        }
        if ($resource_type == 'comcode_page') {
            return array(); // Can not be done
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return array();
        }
        $module = $cma_info['permissions_type_code'];

        $members = $cma_info['connection']->query_select('member_privileges', array('member_id', 'privilege', 'the_value'), array('module_the_name' => $module, 'category_name' => $category, 'the_page' => '', 'active_until' => null));
        $ret = array();
        foreach ($members as $member) {
            $ret[$member['member_id']][$member['privilege']] = strval($member['the_value']);
        }
        return $ret;
    }

    /*
    JSON FILE HANDLING: OUR DEFAULT PROPERTY LIST SERIALISATION/DESERIALISATION
    */

    /**
     * Load function for resource-fs (for files). Finds the data for some resource from a resource-fs JSON file.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @return ~string Resource data (false: error)
     */
    public function file_load_json($filename, $path)
    {
        $properties = $this->file_load($filename, $path);
        if ($properties === false) {
            return false;
        }
        return json_encode($properties);
    }

    /**
     * Load function for resource-fs (for folders). Finds the data for some resource from a resource-fs JSON folder.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @return ~string Resource data (false: error)
     */
    public function folder_load_json($filename, $path)
    {
        $properties = $this->folder_load($filename, $path);
        if ($properties === false) {
            return false;
        }
        return json_encode($properties);
    }

    /**
     * Save function for resource-fs (for files). Parses the data for some resource to a resource-fs JSON file.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  string $data Resource data
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function file_save_json($filename, $path, $data)
    {
        $properties = ($data == '') ? array() : @json_decode($data, true);
        if ($properties === false) {
            return false;
        }
        return $this->file_save($filename, $path, $properties);
    }

    /**
     * Save function for resource-fs (for files).
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path to save at (blank: root / not applicable)
     * @param  array $properties Properties
     * @param  ?ID_TEXT $search_label_as Whether to look for existing records using $filename as a label and this resource type (null: $filename is a strict file name)
     * @param  ?ID_TEXT $search_path Search path (null: the same as the path saving at)
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function file_save($filename, $path, $properties, $search_label_as = null, $search_path = null)
    {
        if (is_null($search_path)) {
            $search_path = $path;
        }

        $label = $filename;
        if ($search_label_as !== null) {
            $filename = $this->convert_label_to_filename($label, $search_path, $search_label_as, true);
        }

        if (($GLOBALS['RESOURCE_FS_ADD_ONLY']) && ($filename !== null)) {
            $resource_id = $this->file_convert_filename_to_id($filename);
            if ($resource_id !== null) {
                return $resource_id;
            }
        }

        $existing = mixed();
        $existing = ($filename === null) ? false : $this->file_load($filename, $search_path); // NB: Even if it has a wildcard path, it should be acceptable to file_load, as the path is not used for search, only for identifying resource type
        if ($existing === false) {
            resource_fs_logging('Added a new ' . $path . '/' . $label . ' file record (i.e. not an edit)', 'inform');

            $resource_id = $this->file_add($label, $path, $properties, $search_label_as);
            $this->_log_if_save_matchup($search_label_as, $resource_id, $path, $properties);
            return $resource_id;
        }

        $resource_id = $this->file_edit($filename, $path, $properties + $existing);
        $this->_log_if_save_matchup($search_label_as, $resource_id, $path, $properties);
        return $resource_id;
    }

    /**
     * Save function for resource-fs (for folders). Parses the data for some resource to a resource-fs JSON folder.
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  string $data Resource data
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function folder_save_json($filename, $path, $data)
    {
        $properties = @json_decode($data, true);
        if ($properties === false) {
            return false;
        }
        return $this->folder_save($filename, $path, $properties);
    }

    /**
     * Save function for resource-fs (for folders).
     *
     * @param  ID_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     * @param  array $properties Properties
     * @param  ?ID_TEXT $search_label_as Whether to look for existing records using $filename as a label and this resource type (null: $filename is a strict file name)
     * @param  ?ID_TEXT $search_path Search path (null: the same as the path saving at)
     * @return ~ID_TEXT The resource ID (false: error, could not create via these properties / here)
     */
    public function folder_save($filename, $path, $properties, $search_label_as = null, $search_path = null)
    {
        if (is_null($search_path)) {
            $search_path = $path;
        }

        $label = $filename;
        if ($search_label_as !== null) {
            $filename = $this->convert_label_to_filename($label, $search_path, $search_label_as, true);
        }

        if (($GLOBALS['RESOURCE_FS_ADD_ONLY']) && ($filename !== null)) {
            $resource_id = $this->folder_convert_filename_to_id($filename);
            if ($resource_id !== null) {
                return $resource_id;
            }
        }

        $existing = mixed();
        $existing = ($filename === null) ? false : $this->folder_load($filename, $search_path); // NB: Even if it has a wildcard path, it should be acceptable to file_load, as the path is not used for search, only for identifying resource type
        if ($existing === false) {
            resource_fs_logging('Added a new ' . $path . '/' . $label . ' folder record (i.e. not an edit)', 'inform');

            $resource_id = $this->folder_add($label, $path, $properties, $search_label_as);
            $this->_log_if_save_matchup($search_label_as, $resource_id, $path, $properties);
            return $resource_id;
        }

        $resource_id = $this->folder_edit($filename, $path, $properties + $existing);
        $this->_log_if_save_matchup($search_label_as, $resource_id, $path, $properties);
        return $resource_id;
    }

    /*
    STANDARD FEATURE INCORPORATION
    */

    /**
     * Extend a resource with extra properties from standard features a resource type supports.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @param  array $properties Details of properties
     * @param  SHORT_TEXT $filename Filename
     * @param  string $path The path (blank: root / not applicable)
     */
    protected function _resource_load_extend($resource_type, $resource_id, &$properties, $filename, $path)
    {
        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $connection = $cma_info['connection'];

        $reserved_fields = array(
            'alternative_ids',
            'url_id_monikers',
            'attachments',
            'content_privacy',
            'content_privacy__members',
            'content_reviews',
            'comments',
            'reviews',
            'ratings',
            'trackbacks',
            'access',
            'access__members',
            'privileges',
            'privileges__members',
        );
        if (array_intersect(array_keys($properties), $reserved_fields) != array()) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }

        // Alternative IDs
        $properties['alternative_ids'] = table_to_portable_rows('alternative_ids', /*skip*/array('resource_moniker', 'resource_label'), array('resource_type' => $resource_type, 'resource_id' => $resource_id), $connection);

        // URL monikers
        if ($cma_info['support_url_monikers']) {
            $page_bits = explode(':', $cma_info['view_page_link_pattern']);
            $properties['url_id_monikers'] = table_to_portable_rows('url_id_monikers', /*skip*/array('id'), array('m_resource_page' => $page_bits[1], 'm_resource_type' => isset($page_bits[2]) ? $page_bits[2] : '', 'm_resource_id' => $resource_id), $connection);
        }

        // Attachments
        if (!is_null($cma_info['attachment_hook'])) {
            $attachment_refs_rows = collapse_1d_complexity('a_id', $connection->query_select('attachment_refs', array('a_id'), array('r_referer_type' => $cma_info['attachment_hook'], 'r_referer_id' => $resource_id)));
            $properties['attachments'] = array();
            foreach ($attachment_refs_rows as $attachment_id) {
                $attachment_rows = table_to_portable_rows('attachments', /*skip*/array(), array('id' => $attachment_id), $connection);
                if (isset($attachment_rows[0])) {
                    $properties['attachments'][] = $attachment_rows[0] + array('_foreign_id' => $attachment_id);
                }
            }
        }

        // Content privacy
        if ($cma_info['support_privacy']) {
            $properties['content_privacy'] = table_to_portable_rows('content_privacy', /*skip*/array(), array('content_type' => $resource_type, 'content_id' => $resource_id), $connection);
            $properties['content_privacy__members'] = table_to_portable_rows('content_privacy__members', /*skip*/array(), array('content_type' => $resource_type, 'content_id' => $resource_id), $connection);
        }

        // Content reviews (by staff)
        if ($cma_info['support_content_reviews'] && addon_installed('content_reviews')) {
            $properties['content_reviews'] = table_to_portable_rows('content_reviews', /*skip*/array(), array('content_type' => $resource_type, 'content_id' => $resource_id), $connection);
        }

        if (!is_null($cma_info['feedback_type_code'])) {
            if (get_forum_type() == 'cns') {
                // Comments & Reviews
                require_code('feedback');
                $topic_id = $GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier(find_overridden_comment_forum($cma_info['feedback_type_code']), $cma_info['feedback_type_code'] . '_' . $resource_id, do_lang('COMMENT'));
                if (!is_null($topic_id)) {
                    $comments = get_resource_fs_record('topic', strval($topic_id));
                    if (!is_null($comments)) {
                        $properties['comments'] = json_decode($comments[0], true);

                        $properties['comments']['posts'] = array();
                        $posts = $GLOBALS['FORUM_DB']->query_select('f_posts', array('id'), array('p_topic_id' => $topic_id), 'ORDER BY p_time ASC,id ASC');
                        foreach ($posts as $_post) {
                            $post = get_resource_fs_record('post', strval($_post['id']));
                            $properties['comments']['posts'][] = json_decode($post[0], true);
                        }
                    }
                }

                $properties['reviews'] = table_to_portable_rows('review_supplement', /*skip*/array('id'), array('r_rating_type' => $cma_info['feedback_type_code'], 'r_rating_for_id' => $resource_id), $connection);
                // NB: r_topic_id and r_post_id will automatically be made portable, so associated with the correct comment
            }

            // Ratings
            $properties['ratings'] = table_to_portable_rows('rating', /*skip*/array('id'), array('rating_for_type' => $cma_info['feedback_type_code'], 'rating_for_id' => $resource_id), $connection);

            // Trackbacks
            $properties['trackbacks'] = table_to_portable_rows('trackbacks', /*skip*/array('id'), array('trackback_for_type' => $cma_info['feedback_type_code'], 'trackback_for_id' => $resource_id), $connection);
        }

        // Custom fields
        if ($cma_info['support_custom_fields']) {
            $properties += $this->_custom_fields_load($resource_type, $resource_id);
        }

        // Permissions
        if (!is_null($cma_info['permissions_type_code']) && $cma_info['is_category'] && $cma_info['category_field'] == $cma_info['id_field']) {
            $properties['access'] = $this->get_resource_access(null, $resource_type, $resource_id);

            $properties['access__members'] = $this->get_resource_access__members(null, $resource_type, $resource_id);

            $properties['privileges'] = $this->get_resource_privileges(null, $resource_type, $resource_id);

            $properties['privileges__members'] = $this->get_resource_privileges__members(null, $resource_type, $resource_id);
        }

        // Properties not used for anything, but interesting
        $properties['comment__resource_type'] = $resource_type;
        $properties['comment__resource_id'] = $resource_id;
        $properties['comment__path'] = $path;
        $properties['comment__filename'] = $filename;
        //$properties['comment__generation_time'] = remap_time_as_portable(time()); Would break git history
        if (isset($properties['edit_date'])) {
            $properties['comment__edit_date_note'] = 'You may remove the edit_date if you want it to auto-generate to the current-time when saving';
        }
    }

    /**
     * Modify standard properties as may be needed by implications of extra properties.
     *
     * @param  array $properties Details of properties (returned by reference)
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $filename Filename
     * @param  LONG_TEXT $label Resource label
     */
    protected function _resource_save_extend_pre(&$properties, $resource_type, $filename, $label)
    {
        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $connection = $cma_info['connection'];

        // New Attachment IDs need generating and substituting
        if (!is_null($cma_info['attachment_hook'])) {
            if (isset($properties['attachments'])) {
                $new_id = $connection->query_select_value_if_there('attachments', 'MAX(id)');
                if (is_null($new_id)) {
                    $new_id = db_get_first_id();
                } else {
                    $new_id++;
                }

                $attachments = &$properties['attachments'];
                foreach ($attachments as &$attachment) {
                    $foreign_id = $attachment['_foreign_id'];

                    foreach ($properties as &$val) {
                        if (is_string($val)) {
                            $val = preg_replace('#(\[attachment( .*)?\])' . strval($foreign_id) . '(\[/attachment\])#U', '$1' . strval($new_id) . '$3', $val);
                            $val = preg_replace('#(\[attachment_safe( .*)?\])' . strval($foreign_id) . '(\[/attachment_safe\])#U', '$1' . strval($new_id) . '$3', $val);
                        }
                    }

                    $attachment['_new_id'] = $new_id;
                    $new_id++;
                }
            }
        }
    }

    /**
     * Save extra properties from standard features a resource type supports.
     *
     * @param  ID_TEXT $resource_type The resource type
     * @param  ID_TEXT $resource_id The resource ID
     * @param  ID_TEXT $filename Filename
     * @param  LONG_TEXT $label Resource label
     * @param  array $properties Details of properties
     */
    protected function _resource_save_extend($resource_type, $resource_id, $filename, $label, $properties)
    {
        if (cms_mb_strlen($filename) > 80) { // Filename has may just have been the label from an add operation, in which case we must chop it down
            $filename = cms_mb_substr($filename, 0, 80);
        }

        $cma_info = $this->_get_cma_info($resource_type);
        if ($cma_info === null) {
            return;
        }
        $connection = $cma_info['connection'];

        // Alternative IDs
        if (isset($properties['alternative_ids'])) {
            foreach ($properties['alternative_ids'] as &$alternative_id) {
                $alternative_id['resource_moniker'] = basename($filename, '.' . RESOURCE_FS_DEFAULT_EXTENSION);
                $alternative_id['resource_label'] = $label;
            }
            table_from_portable_rows('alternative_ids', $properties['alternative_ids'], array('resource_type' => $resource_type, 'resource_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
        }

        // URL monikers
        if ($cma_info['support_url_monikers']) {
            if (isset($properties['url_id_monikers'])) {
                $page_bits = explode(':', $cma_info['view_page_link_pattern']);
                table_from_portable_rows('url_id_monikers', $properties['url_id_monikers'], array('m_resource_page' => $page_bits[1], 'm_resource_type' => $page_bits[2], 'm_resource_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }
        }

        // Attachments
        if (!is_null($cma_info['attachment_hook'])) {
            if (isset($properties['attachments'])) {
                $attachments = $properties['attachments'];

                // Delete old attachments
                require_code('attachments3');
                delete_comcode_attachments($cma_info['attachment_hook'], $resource_id, $connection, true);

                // Metadata
                $db_fields = collapse_2d_complexity('m_name', 'm_type', $connection->query_select('db_meta', array('m_name', 'm_type'), array('m_table' => 'attachments')));
                $relation_map = get_relation_map_for_table('attachments');

                // Insert new attachments
                foreach ($attachments as $attachment) {
                    $foreign_id = $attachment['_foreign_id'];
                    unset($attachment['_foreign_id']);
                    $new_attachment_id = $attachment['_new_id'];
                    unset($attachment['_new_id']);

                    $attachment_row = table_row_from_portable_row($attachment, $db_fields, $relation_map);
                    $attachment_row['id'] = $new_attachment_id;
                    $connection->query_insert('attachments', $attachment_row);
                    $connection->query_insert('attachment_refs', array('r_referer_type' => $cma_info['attachment_hook'], 'r_referer_id' => $resource_id, 'a_id' => $new_attachment_id));
                }
            }
        }

        // Content privacy
        if ($cma_info['support_privacy']) {
            if (isset($properties['content_privacy'])) {
                table_from_portable_rows('content_privacy', $properties['content_privacy'], array('content_type' => $resource_type, 'content_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }

            if (isset($properties['content_privacy__members'])) {
                table_from_portable_rows('content_privacy__members', $properties['content_privacy__members'], array('content_type' => $resource_type, 'content_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }
        }

        // Content reviews (by staff)
        if ($cma_info['support_content_reviews']) {
            if (isset($properties['content_reviews'])) {
                table_from_portable_rows('content_reviews', $properties['content_reviews'], array('content_type' => $resource_type, 'content_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }
        }

        if (!is_null($cma_info['feedback_type_code'])) {
            if (get_forum_type() == 'cns') {
                // Comments & Reviews
                if (isset($properties['comments'])) {
                    $comments = $properties['comments'];
                    $comments['description'] = preg_replace('#^(.*: ) .*$#', '$1 ' . $cma_info['feedback_type_code'] . '_' . $resource_id, $comments['description']);

                    $forum_name = find_overridden_comment_forum($cma_info['feedback_type_code']);
                    require_code('feedback');
                    $topic_id = $GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier($forum_name, $cma_info['feedback_type_code'] . '_' . $resource_id, do_lang('COMMENT'));
                    if (is_null($topic_id)) {
                        $forum_id = $GLOBALS['FORUM_DRIVER']->forum_id_from_name($forum_name);
                        $resource_fs_path = $comments['comment__path'] . '/' . $comments['comment__filename'];
                    } else {
                        $resource_fs_path = find_commandr_fs_filename_via_id('topic', strval($topic_id), true);
                    }

                    // Save topic
                    $resource_fs_ob = get_resource_commandr_fs_object('topic');
                    $_topic_id = $resource_fs_ob->resource_save('topic', basename($resource_fs_path), dirname($resource_fs_path), $comments);
                    if ($_topic_id === false) {
                        fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
                    }
                    $resource_fs_path_topic = find_commandr_fs_filename_via_id('topic', $_topic_id, true);

                    // Save each post
                    $resource_fs_ob = get_resource_commandr_fs_object('post');
                    foreach ($comments['posts'] as $post) {
                        $resource_fs_path_post = $resource_fs_path_topic . '/' . $post['comment__filename'];
                        $test = $resource_fs_ob->resource_save('post', basename($resource_fs_path_post), dirname($resource_fs_path_post), $post);
                        if ($test === false) {
                            fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
                        }
                    }
                }
                if (isset($properties['reviews'])) {
                    table_from_portable_rows('review_supplement', $properties['reviews'], array('r_rating_type' => $cma_info['feedback_type_code'], 'r_rating_for_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
                }
            }

            // Ratings
            if (isset($properties['ratings'])) {
                table_from_portable_rows('rating', $properties['ratings'], array('rating_for_type' => $cma_info['feedback_type_code'], 'rating_for_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }

            // Trackbacks
            if (isset($properties['trackbacks'])) {
                table_from_portable_rows('trackbacks', $properties['trackbacks'], array('trackback_for_type' => $cma_info['feedback_type_code'], 'trackback_for_id' => $resource_id), TABLE_REPLACE_MODE_BY_EXTRA_FIELD_DATA, $connection);
            }
        }

        // Custom fields
        if ($cma_info['support_custom_fields']) {
            $this->_custom_fields_save($resource_type, $resource_id, $filename, $label, $properties);
        }

        // Permissions
        if (!is_null($cma_info['permissions_type_code']) && $cma_info['is_category'] && $cma_info['category_field'] == $cma_info['id_field']) {
            if (isset($properties['access'])) {
                $groups = $properties['access'];
                $this->set_resource_access(null, $groups, $resource_type, $resource_id);
            }

            if (isset($properties['access__members'])) {
                $members = $properties['access__members'];
                $this->set_resource_access__members(null, $members, $resource_type, $resource_id);
            }

            if (isset($properties['privileges'])) {
                $group_settings = $properties['privileges'];
                $this->set_resource_privileges(null, $group_settings, $resource_type, $resource_id);
            }

            if (isset($properties['privileges__members'])) {
                $member_settings = $properties['privileges__members'];
                $this->set_resource_privileges__members(null, $member_settings, $resource_type, $resource_id);
            }
        }
    }

    /**
     * Find details of custom properties.
     *
     * @param  ID_TEXT $type The resource type
     * @return array Details of properties
     */
    protected function _custom_fields_enumerate_properties($type)
    {
        static $cache = array();
        if (array_key_exists($type, $cache)) {
            return $cache[$type];
        }

        $cma_info = $this->_get_cma_info($type);
        if ($cma_info === null) {
            return array();
        }
        $connection = $cma_info['connection'];

        require_code('fields');
        if (!has_tied_catalogue($type)) {
            return array();
        }

        $props = array();

        $fields = get_catalogue_fields('_' . $type);
        foreach ($fields as $field_bits) {
            $cf_name = get_translated_text($field_bits['cf_name'], $connection);
            $fixed_id = 'custom__' . fix_id($cf_name);
            if (!array_key_exists($fixed_id, $props)) {
                $key = $fixed_id;
            } else {
                $key = 'custom__field_' . strval($field_bits['id']);
            }

            require_code('fields');
            $ob = get_fields_hook($field_bits['cf_type']);
            list(, , $storage_type) = $ob->get_field_value_row_bits(array('id' => null, 'cf_type' => $field_bits['cf_type'], 'cf_default' => ''));
            $_type = 'SHORT_TEXT';
            switch ($storage_type) {
                case 'short_trans':
                    $_type = 'SHORT_TRANS';
                    break;
                case 'long_trans':
                    $_type = 'LONG_TRANS';
                    break;
                case 'long':
                    $_type = 'LONG_TEXT';
                    break;
                case 'integer':
                    $_type = 'INTEGER';
                    break;
                case 'float':
                    $_type = 'REAL';
                    break;
            }
            $props[$key] = $_type;
        }

        $cache[$type] = $props;

        return $props;
    }

    /**
     * Load custom properties.
     *
     * @param  ID_TEXT $type The resource type
     * @param  ID_TEXT $id The content ID
     * @return array Loaded properties
     */
    protected function _custom_fields_load($type, $id)
    {
        if (!addon_installed('catalogues')) {
            return array();
        }

        require_code('fields');
        if (!has_tied_catalogue($type)) {
            return array();
        }

        $cma_info = $this->_get_cma_info($type);
        if ($cma_info === null) {
            return array();
        }
        $connection = $cma_info['connection'];

        $properties = array();

        require_code('catalogues');

        $catalogue_entry_id = get_bound_content_entry($type, $id);
        if (!is_null($catalogue_entry_id)) {
            $special_fields = get_catalogue_entry_field_values('_' . $type, $catalogue_entry_id);
        } else {
            $special_fields = $connection->query_select('catalogue_fields', array('*'), array('c_name' => '_' . $type), 'ORDER BY cf_order,' . $GLOBALS['SITE_DB']->translate_field_ref('cf_name'));
        }

        $prop_names = array_keys($this->_custom_fields_enumerate_properties($type));
        foreach ($special_fields as $i => $field) {
            $default = $field['cf_default'];
            if (array_key_exists('effective_value_pure', $field)) {
                $default = $field['effective_value_pure'];
            } elseif (array_key_exists('effective_value', $field)) {
                $default = $field['effective_value'];
            }

            $prop_name = $prop_names[$i];
            $properties[$prop_name] = $default;
        }

        return $properties;
    }

    /**
     * Save custom properties.
     *
     * @param  ID_TEXT $type The resource type
     * @param  ID_TEXT $id The content ID
     * @param  ID_TEXT $filename Filename
     * @param  LONG_TEXT $label Resource label
     * @param  array $properties Properties to save
     */
    protected function _custom_fields_save($type, $id, $filename, $label, $properties)
    {
        if (!addon_installed('catalogues')) {
            return;
        }

        require_code('fields');
        if (!has_tied_catalogue($type)) {
            return;
        }

        $cma_info = $this->_get_cma_info($type);
        if ($cma_info === null) {
            return;
        }
        $connection = $cma_info['connection'];

        $existing = get_bound_content_entry($type, $id);

        require_code('catalogues');

        // Get field values
        $fields = $connection->query_select('catalogue_fields', array('*'), array('c_name' => '_' . $type), 'ORDER BY cf_order,' . $GLOBALS['SITE_DB']->translate_field_ref('cf_name'));
        $map = array();
        require_code('fields');
        $prop_names = array_keys($this->_custom_fields_enumerate_properties($type));
        foreach ($fields as $i => $field) {
            $prop_name = $prop_names[$i];
            if (!array_key_exists($prop_name, $properties)) {
                $properties[$prop_name] = '';
            }
            $map[$field['id']] = $properties[$prop_name];
        }

        $first_cat = $connection->query_select_value('catalogue_categories', 'MIN(id)', array('c_name' => '_' . $type));

        require_code('catalogues2');

        if (!is_null($existing)) {
            actual_edit_catalogue_entry($existing, $first_cat, 1, '', 0, 0, 0, $map);
        } else {
            $catalogue_entry_id = actual_add_catalogue_entry($first_cat, 1, '', 0, 0, 0, $map);

            $connection->query_insert('catalogue_entry_linkage', array(
                'catalogue_entry_id' => $catalogue_entry_id,
                'content_type' => $type,
                'content_id' => $id,
            ));
        }
    }

    /*
    COMMANDR-FS BINDING
    */

    /**
     * Standard Commandr-fs listing function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return ~array The final directory listing (false: failure)
     */
    public function listing($meta_dir, $meta_root_node, &$commandr_fs)
    {
        if (!$this->_is_active()) {
            return false;
        }

        $listing = array();

        $folder_types = is_array($this->folder_resource_type) ? $this->folder_resource_type : (is_null($this->folder_resource_type) ? array() : array($this->folder_resource_type));
        $file_types = is_array($this->file_resource_type) ? $this->file_resource_type : (is_null($this->file_resource_type) ? array() : array($this->file_resource_type));

        // Find where we're at
        $cat_id = '';
        $cat_resource_type = mixed();
        if (count($meta_dir) != 0) {
            if (is_null($this->folder_resource_type)) {
                return false; // Should not be possible
            }

            list($cat_resource_type, $cat_id) = $this->folder_convert_filename_to_id(implode('/', $meta_dir));
        }

        // Find folders
        foreach ($folder_types as $resource_type) {
            $folder_info = $this->_get_cma_info($resource_type);
            if ($folder_info === null) {
                continue;
            }

            $relationship = $this->_has_parent_child_relationship($cat_resource_type, $resource_type);
            if (is_null($relationship)) {
                continue;
            }

            $_cat_id = ($relationship['cat_field_numeric'] ? (($cat_id == '') ? null : intval($cat_id)) : $cat_id);

            $select = array('main.*');
            $table = $folder_info['table'] . ' main';
            if ((!is_null($relationship['linker_table'])) && ($relationship['linker_table'] != $folder_info['table'])) {
                if ((!is_null($_cat_id)) && ($_cat_id !== '')) {
                    $table = $folder_info['table'] . ' main JOIN ' . $folder_info['connection']->get_table_prefix() . $relationship['linker_table'] . ' cats ON cats.' . $relationship['id_field_linker'] . '=main.' . $relationship['id_field'];
                }
            }
            if (!is_null($folder_info['add_time_field'])) {
                $select[] = 'main.' . $folder_info['add_time_field'];
            }
            if (!is_null($folder_info['edit_time_field'])) {
                $select[] = 'main.' . $folder_info['edit_time_field'];
            }
            if (!is_array($folder_info['id_field'])) {
                $select[] = 'main.' . $folder_info['id_field'];
            }
            $extra = '';
            if (can_arbitrary_groupby()) {
                $extra .= 'GROUP BY main.' . $relationship['id_field'] . ' '; // In case it's not a real category table, just an implied one by self-categorisation of entries
            }
            $extra .= 'ORDER BY main.' . $relationship['id_field'];
            if (is_null($relationship['cat_field'])) {
                $where = array();
            } else {
                if (((is_null($_cat_id)) || ($_cat_id === '')) && ($relationship['linker_table'] != $folder_info['table'])) {
                    $where = array($relationship['id_field'] => ($folder_info['id_field_numeric'] ? db_get_first_id() : '')); // Don't go through the linker table for the root category
                } else {
                    $where = array($relationship['cat_field'] => $_cat_id);
                }
            }
            $select = array_unique($select);
            $child_folders = $folder_info['connection']->query_select($table, $select, $where, $extra, 10000/*Reasonable limit*/);
            foreach ($child_folders as $folder) {
                $str_id = extract_content_str_id_from_data($folder, $folder_info);
                $filename = $this->folder_convert_id_to_filename($resource_type, $str_id);

                $filetime = mixed();
                if (method_exists($this, '_get_folder_edit_date')) {
                    $filetime = $this->_get_folder_edit_date($folder, end($meta_dir));
                }
                if (is_null($filetime)) {
                    if (!is_null($folder_info['edit_time_field'])) {
                        $filetime = $folder[$folder_info['edit_time_field']];
                    }
                    if (is_null($filetime)) {
                        if (!is_null($folder_info['add_time_field'])) {
                            $filetime = $folder[$folder_info['add_time_field']];
                        }
                    }
                }

                $listing[] = array(
                    $filename,
                    COMMANDR_FS_DIR,
                    null/*don't calculate a filesize*/,
                    $filetime,
                );
            }
        }

        // Find files
        foreach ($file_types as $resource_type) {
            $file_info = $this->_get_cma_info($resource_type);
            if ($file_info === null) {
                continue;
            }

            $relationship = $this->_has_parent_child_relationship($cat_resource_type, $resource_type);
            if (is_null($relationship)) {
                continue;
            }

            $where = array();
            if (!is_null($this->folder_resource_type)) {
                $_cat_id = ($relationship['cat_field_numeric'] ? (($cat_id == '') ? null : intval($cat_id)) : $cat_id);
                $where[$relationship['cat_field']] = $_cat_id;
            }

            $select = array();
            append_content_select_for_id($select, $file_info);
            if (!is_null($file_info['add_time_field'])) {
                $select[] = $file_info['add_time_field'];
            }
            if (!is_null($file_info['edit_time_field'])) {
                $select[] = $file_info['edit_time_field'];
            }
            if (!is_array($file_info['id_field'])) {
                $select[] = $file_info['id_field'];
            }
            $select = array_unique($select);
            $files = $file_info['connection']->query_select($file_info['table'], $select, $where, '', 10000/*Reasonable limit*/);
            foreach ($files as $file) {
                $str_id = extract_content_str_id_from_data($file, $file_info);
                $filename = $this->file_convert_id_to_filename($resource_type, $str_id);

                $filetime = mixed();
                if (method_exists($this, '_get_file_edit_date')) {
                    $filetime = $this->_get_file_edit_date($file, end($meta_dir));
                }
                if (is_null($filetime)) {
                    if (!is_null($file_info['edit_time_field'])) {
                        $filetime = $file[$file_info['edit_time_field']];
                    }
                    if (is_null($filetime)) {
                        if (!is_null($file_info['add_time_field'])) {
                            $filetime = $file[$file_info['add_time_field']];
                        }
                    }
                }

                $listing[] = array(
                    $filename,
                    COMMANDR_FS_FILE,
                    null/*don't calculate a filesize*/,
                    $filetime,
                );
            }
        }

        if ($cat_id != '') { // File for editing the folder's own properties
            list($cat_resource_type, $cat_id) = $this->folder_convert_filename_to_id(implode('/', $meta_dir));
            require_code('content');
            $folder_info = $this->_get_cma_info($cat_resource_type);
            if ($folder_info !== null) {
                $folder = content_get_row($cat_id, $folder_info);

                $filetime = mixed();
                if (method_exists($this, '_get_file_edit_date')) {
                    $filetime = $this->_get_folder_edit_date($folder, end($meta_dir));
                }
                if (is_null($filetime)) {
                    if (!is_null($folder_info['edit_time_field'])) {
                        $filetime = $folder[$folder_info['edit_time_field']];
                    }
                    if (is_null($filetime)) {
                        if (!is_null($folder_info['add_time_field'])) {
                            $filetime = $folder[$folder_info['add_time_field']];
                        }
                    }
                }

                $listing[] = array(
                    RESOURCE_FS_SPECIAL_DIRECTORY_FILE,
                    COMMANDR_FS_FILE,
                    null/*don't calculate a filesize*/,
                    $filetime,
                );
            }
        }

        return $listing;
    }

    /**
     * Standard Commandr-fs directory creation function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  string $new_dir_name The new directory name
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return boolean Success?
     */
    public function make_directory($meta_dir, $meta_root_node, $new_dir_name, &$commandr_fs)
    {
        if (is_null($this->folder_resource_type)) {
            return false;
        }
        return $this->folder_add($new_dir_name, implode('/', $meta_dir), array());
    }

    /**
     * Standard Commandr-fs directory removal function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  string $dir_name The directory name
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return boolean Success?
     */
    public function remove_directory($meta_dir, $meta_root_node, $dir_name, &$commandr_fs)
    {
        if (is_null($this->folder_resource_type)) {
            return false;
        }
        return $this->folder_delete($dir_name, implode('/', $meta_dir));
    }

    /**
     * Standard Commandr-fs file reading function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  string $file_name The file name
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return ~string The file contents (false: failure)
     */
    public function read_file($meta_dir, $meta_root_node, $file_name, &$commandr_fs)
    {
        if ($file_name == RESOURCE_FS_SPECIAL_DIRECTORY_FILE) {
            return $this->folder_load__flat(array_pop($meta_dir), implode('/', $meta_dir));
        }
        return $this->file_load__flat($file_name, implode('/', $meta_dir));
    }

    /**
     * Standard Commandr-fs file writing function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  string $file_name The file name
     * @param  string $contents The new file contents
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return boolean Success?
     */
    public function write_file($meta_dir, $meta_root_node, $file_name, $contents, &$commandr_fs)
    {
        if ($file_name == RESOURCE_FS_SPECIAL_DIRECTORY_FILE) {
            return $this->folder_save__flat(array_pop($meta_dir), implode('/', $meta_dir), $contents) !== false;
        }
        return $this->file_save__flat($file_name, implode('/', $meta_dir), $contents) !== false;
    }

    /**
     * Standard Commandr-fs file removal function for Commandr-fs hooks.
     *
     * @param  array $meta_dir The current meta-directory path
     * @param  string $meta_root_node The root node of the current meta-directory
     * @param  string $file_name The file name
     * @param  object $commandr_fs A reference to the Commandr filesystem object
     * @return boolean Success?
     */
    public function remove_file($meta_dir, $meta_root_node, $file_name, &$commandr_fs)
    {
        if ($file_name == RESOURCE_FS_SPECIAL_DIRECTORY_FILE) {
            return true; // Fake success, as needs to do so when deleting folder contents
        }
        return $this->file_delete($file_name, implode('/', $meta_dir));
    }
}
