<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing 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  Christopher Graham
 * @package    catalogues
 */

/**
 * Create an eCommerce catalogue.
 *
 * @param  ID_TEXT $catalogue_name Catalogue name
 * @return AUTO_LINK The category ID
 */
function create_ecommerce_catalogue(string $catalogue_name) : int
{
    actual_add_catalogue(
        $catalogue_name,
        lang_code_to_default_content('c_title', 'DEFAULT_CATALOGUE_PRODUCTS_TITLE', false, 2),
        lang_code_to_default_content('c_description', 'DEFAULT_CATALOGUE_PRODUCTS_DESCRIPTION', true, 2),
        C_DT_GRID,
        1,
        '',
        0,
        1
    );
    $category_id = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'id', ['c_name' => $catalogue_name]);

    $fields = [
        // Name, Description, Type, Defines order, Required, Visible, Categorised, Searchable, Default, Field options
        ['ECOM_CAT_product_title', 'DESCRIPTION_TITLE', 'short_trans', 1, 1, 1, 1, '', 'ecommerce_tag=product_title'],
        ['ECOM_CAT_sku', 'ECOM_CATD_sku', 'codename', 0, 0, 1, 1, '', 'default=RANDOM,ecommerce_tag=sku'],
        ['ECOM_CAT_price', 'ECOM_CATD_price', 'float', 0, 1, 1, 1, '', 'ecommerce_tag=price,decimal_points_behaviour=price'],
        ['ECOM_CAT_stock_level', 'ECOM_CATD_stock_level', 'integer', 0, 0, 1, 0, '', 'ecommerce_tag=stock_level'],
        ['ECOM_CAT_stock_level_warn_at', 'ECOM_CATD_stock_level_warn_at', 'integer', 0, 0, 0, 0, '', 'ecommerce_tag=stock_level_warn_at'],
        ['ECOM_CAT_stock_level_maintain', 'ECOM_CATD_stock_level_maintain', 'list', 0, 1, 0, 0, do_lang('YES') . '|' . do_lang('NO'), 'ecommerce_tag=stock_level_maintain'],
        ['ECOM_CAT_tax_code', 'ECOM_CATD_tax_code', 'tax_code', 0, 1, 0, 0, '', 'ecommerce_tag=tax_code'],
        ['ECOM_CAT_image', 'ECOM_CATD_image', 'picture', 0, 0, 1, 1, '', 'ecommerce_tag=image'],
        ['ECOM_CAT_weight', 'ECOM_CATD_weight', 'float', 0, 1, 0, 0, '', 'ecommerce_tag=weight'],
        ['ECOM_CAT_length', 'ECOM_CATD_length', 'float', 0, 1, 0, 0, '', 'ecommerce_tag=length'],
        ['ECOM_CAT_width', 'ECOM_CATD_width', 'float', 0, 1, 0, 0, '', 'ecommerce_tag=width'],
        ['ECOM_CAT_height', 'ECOM_CATD_height', 'float', 0, 1, 0, 0, '', 'ecommerce_tag=height'],
        ['ECOM_CAT_description', 'DESCRIPTION_DESCRIPTION', 'long_trans', 0, 0, 1, 1, '', 'ecommerce_tag=description'],
    ];

    foreach ($fields as $i => $field) {
        actual_add_catalogue_field(
            $catalogue_name, // $catalogue_name
            lang_code_to_default_content('cf_name', $field[0], false, 3), // $name
            lang_code_to_default_content('cf_description', $field[1], false, 3), // $description
            $field[2], // $type
            $i, // $order
            $field[3], // $defines_order
            $field[5], // $visible
            0, // $sensitive
            array_key_exists(7, $field) ? $field[7] : '', // $default
            $field[4], // $required
            $field[6],
            $field[6],
            0,
            array_key_exists(5, $field) ? $field[5] : 0, // $put_in_category
            array_key_exists(5, $field) ? $field[5] : 0, // $put_in_search
            array_key_exists(8, $field) ? $field[8] : '' // $field_options
        );
    }

    require_code('permissions2');
    set_global_category_access('catalogues_catalogue', $catalogue_name);
    set_global_category_access('catalogues_category', $category_id);

    return $category_id;
}

/**
 * Converts a non-tree catalogue to a tree catalogue.
 *
 * @param  ID_TEXT $catalogue_name Catalogue name
 */
function catalogue_to_tree(string $catalogue_name)
{
    $_c_title = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogues', 'c_title', ['c_name' => $catalogue_name]);
    if ($_c_title === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue'));
    }
    $new_root = actual_add_catalogue_category($catalogue_name, get_translated_text($_c_title), '', '', null, '');
    $GLOBALS['SITE_DB']->query('UPDATE ' . get_table_prefix() . 'catalogue_categories SET cc_parent_id=' . strval($new_root) . ' WHERE id<>' . strval($new_root) . ' AND ' . db_string_equal_to('c_name', $catalogue_name));
    $GLOBALS['SITE_DB']->query_update('catalogues', ['c_is_tree' => 1], ['c_name' => $catalogue_name], '', 1);
}

/**
 * Converts a non-tree catalogue from a tree catalogue.
 *
 * @param  ID_TEXT $catalogue_name Catalogue name
 */
function catalogue_from_tree(string $catalogue_name)
{
    $GLOBALS['SITE_DB']->query_update('catalogue_categories', ['cc_parent_id' => null], ['c_name' => $catalogue_name]);
    $GLOBALS['SITE_DB']->query_update('catalogues', ['c_is_tree' => 0], ['c_name' => $catalogue_name], '', 1);
}

/**
 * Add a catalogue using all the specified values.
 *
 * @param  ID_TEXT $name The codename of the catalogue
 * @param  mixed $title The title of the catalogue (either language string map or string)
 * @param  mixed $description A description (either language string map or string)
 * @param  SHORT_INTEGER $display_type The display type
 * @param  BINARY $is_tree Whether the catalogue uses a tree system (as opposed to mere categories in an index)
 * @param  LONG_TEXT $notes Hidden notes pertaining to this catalogue
 * @param  integer $submit_points How many points a member gets by submitting to this catalogue
 * @param  BINARY $ecommerce Whether the catalogue is an eCommerce catalogue
 * @param  SHORT_TEXT $categories_sort_order Category sort order
 * @set "title ASC" "recent ASC" "recent DESC"
 * @param  ID_TEXT $send_view_reports How to send view reports
 * @set never daily weekly monthly quarterly
 * @param  ?integer $default_review_freq Default review frequency for catalogue entries (null: none)
 * @param  ?TIME $add_time The add time (null: now)
 * @param  boolean $uniqify Whether to force the name as unique, if there's a conflict
 * @return ID_TEXT The name
 */
function actual_add_catalogue(string $name, $title, $description, int $display_type, int $is_tree, string $notes, int $submit_points, int $ecommerce = 0, string $categories_sort_order = 'title ASC', string $send_view_reports = 'never', ?int $default_review_freq = null, ?int $add_time = null, bool $uniqify = false) : string
{
    if ($add_time === null) {
        $add_time = time();
    }

    require_code('type_sanitisation');
    if (!is_alphanumeric($name)) {
        warn_exit(do_lang_tempcode('BAD_CODENAME'));
    }

    // Check doesn't already exist
    $test = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogues', 'c_name', ['c_name' => $name]);
    if ($test !== null) {
        if ($uniqify) {
            $name .= '_' . uniqid('', false);
        } else {
            warn_exit(do_lang_tempcode('ALREADY_EXISTS', escape_html($name)));
        }
    }

    // Create
    $map = [
        'c_name' => $name,
        'c_send_view_reports' => $send_view_reports,
        'c_ecommerce' => $ecommerce,
        'c_categories_sort_order' => $categories_sort_order,
        'c_display_type' => $display_type,
        'c_is_tree' => $is_tree,
        'c_notes' => $notes,
        'c_add_date' => $add_time,
        'c_submit_points' => $submit_points,
        'c_default_review_freq' => $default_review_freq,
    ];
    if (!is_array($title)) {
        $map += insert_lang('c_title', $title, 1);
        $_title = $title;
    } else {
        $map += $title;
        $_title = get_translated_text($title['c_title']);
    }
    if (!is_array($description)) {
        $map += insert_lang_comcode('c_description', $description, 2);
    } else {
        $map += $description;
    }
    $GLOBALS['SITE_DB']->query_insert('catalogues', $map);

    if ($is_tree == 1) {
        // Create root node
        $root_title = ($is_tree == 1) ? do_lang('_HOME', get_translated_text($map['c_title'])) : get_translated_text($map['c_title']);
        $map = [
            'cc_move_days_lower' => 30,
            'cc_move_days_higher' => 60,
            'cc_move_target_id' => null,
            'rep_image' => '',
            'c_name' => $name,
            'cc_notes' => '',
            'cc_add_date' => time(),
            'cc_parent_id' => null,
        ];
        $map += insert_lang('cc_title', $root_title, 1);
        $map += insert_lang_comcode('cc_description', '', 3);
        $category = $GLOBALS['SITE_DB']->query_insert('catalogue_categories', $map, true);
    } else {
        $category = null;
    }

    log_it('ADD_CATALOGUE', $name, $_title);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        generate_resource_fs_moniker('catalogue', $name, null, null, true);
    }

    require_code('member_mentions');
    dispatch_member_mention_notifications('catalogue', $name);

    if (substr($name, 0, 1) == '_') {
        persistent_cache_delete('CONTENT_TYPE_HAS_CUSTOM_FIELDS_CACHE');
    }

    if (function_exists('decache')) {
        delete_cache_entry('_field_type_selection');
    }

    require_code('sitemap_xml');
    notify_sitemap_node_add('_SEARCH:catalogues:index:' . $name);

    return $name;
}

/**
 * Add a field to the specified catalogue, without disturbing any other data in that catalogue.
 *
 * @param  ID_TEXT $c_name The codename of the catalogue the field is for
 * @param  mixed $name The name of the field (either language string map or string)
 * @param  mixed $description A description (either language string map or string)
 * @param  ID_TEXT $type The type of the field
 * @param  ?integer $order The field order (the field order determines what order the fields are displayed within an entry) (null: next)
 * @param  BINARY $defines_order Whether this field defines the catalogue order
 * @param  BINARY $visible Whether this is a visible field
 * @param  BINARY $sensitive Whether this field may contain sensitive or personal data
 * @param  LONG_TEXT $default The default value for the field
 * @param  BINARY $required Whether this field is required
 * @param  BINARY $is_sortable Whether the field is sortable
 * @param  BINARY $include_in_main_search Whether the field is included in main search
 * @param  BINARY $allow_template_search Whether to allow template search
 * @param  BINARY $put_in_category Whether the field is to be shown in category views (not applicable for the list display type)
 * @param  BINARY $put_in_search Whether the field is to be shown in search views (not applicable for the list display type)
 * @param  SHORT_TEXT $options Field options
 * @param  ?AUTO_LINK $id Force this ID (null: auto-increment as normal)
 * @return AUTO_LINK Field ID
 */
function actual_add_catalogue_field(string $c_name, $name, $description = '', string $type = 'short_text', ?int $order = null, int $defines_order = 0, int $visible = 1, int $sensitive = 0, string $default = '', int $required = 0, int $is_sortable = 0, int $include_in_main_search = 0, int $allow_template_search = 0, int $put_in_category = 1, int $put_in_search = 1, string $options = '', ?int $id = null) : int
{
    if ($order === null) {
        $order = $GLOBALS['SITE_DB']->query_select_value('catalogue_fields', 'MAX(cf_order)', ['c_name' => $c_name]);
        if ($order === null) {
            $order = 0;
        } else {
            $order++;
        }
    }

    $map = [
        'c_name' => $c_name,
        'cf_type' => $type,
        'cf_order' => $order,
        'cf_defines_order' => $defines_order,
        'cf_visible' => $visible,
        'cf_sensitive' => $sensitive,
        'cf_default' => $default,
        'cf_required' => $required,
        'cf_is_sortable' => $is_sortable,
        'cf_include_in_main_search' => $include_in_main_search,
        'cf_allow_template_search' => $allow_template_search,
        'cf_put_in_category' => $put_in_category,
        'cf_put_in_search' => $put_in_search,
        'cf_options' => $options,
    ];
    if (!is_array($name)) {
        $map += insert_lang('cf_name', $name, 2);
    } else {
        $map += $name;
    }
    if (!is_array($description)) {
        $map += insert_lang('cf_description', $description, 2);
    } else {
        $map += $description;
    }
    if ($id !== null) {
        $map['id'] = $id;
    }
    $cf_id = $GLOBALS['SITE_DB']->query_insert('catalogue_fields', $map, true);
    if ($id !== null) {
        $cf_id = $id;
    }

    require_code('fields');

    $ob = get_fields_hook($type);

    // Now add field values for all pre-existing entries (in the ideal world, there would be none yet)
    $start = 0;
    do {
        $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLUGGISH);

        send_http_output_ping();

        $entries = collapse_1d_complexity('id', $GLOBALS['SITE_DB']->query_select('catalogue_entries', ['id'], ['c_name' => $c_name], '', 300, $start));
        foreach ($entries as $entry) {
            $default = null;

            list($raw_type, $default, $_type) = $ob->get_field_value_row_bits($map + ['id' => $cf_id], $required == 1, $default);

            $entry_map = ['cf_id' => $cf_id, 'ce_id' => $entry];
            if (strpos($_type, '_trans') !== false) {
                $entry_map += insert_lang_comcode('cv_value', ($default === null) ? '' : $default, 3);
            } elseif ($_type == 'float') {
                $entry_map['cv_value'] = (($default === null) || ($default == '')) ? null : floatval($default);
            } elseif ($_type == 'integer') {
                $entry_map['cv_value'] = (($default === null) || ($default == '')) ? null : intval($default);
            } else {
                $entry_map['cv_value'] = (($default === null) || ($type == 'list')) ? '' : $default;
            }
            $GLOBALS['SITE_DB']->query_insert('catalogue_efv_' . $_type, $entry_map);
        }

        $start += 300;

        cms_set_time_limit($old_limit);
    } while (array_key_exists(0, $entries));

    return $cf_id;
}

/**
 * Edit a catalogue.
 *
 * @param  ID_TEXT $old_name The current name of the catalogue
 * @param  ID_TEXT $name The new name of the catalogue
 * @param  SHORT_TEXT $title The human readable name/title of the catalogue
 * @param  LONG_TEXT $description The description
 * @param  SHORT_INTEGER $display_type The display type
 * @param  LONG_TEXT $notes Admin notes
 * @param  integer $submit_points How many points are given to a member that submits to the catalogue
 * @param  BINARY $ecommerce Whether the catalogue is an eCommerce catalogue
 * @param  SHORT_TEXT $categories_sort_order Category sort order
 * @set "title ASC" "recent ASC" "recent DESC"
 * @param  ID_TEXT $send_view_reports How to send view reports
 * @set never daily weekly monthly quarterly
 * @param  ?integer $default_review_freq Default review frequency for catalogue entries (null: none)
 * @param  ?TIME $add_time Add time (null: do not change)
 * @param  boolean $uniqify Whether to force the name as unique, if there's a conflict
 * @return ID_TEXT The name
 */
function actual_edit_catalogue(string $old_name, string $name, string $title, string $description, int $display_type, string $notes, int $submit_points, int $ecommerce, string $categories_sort_order, string $send_view_reports, ?int $default_review_freq, ?int $add_time = null, bool $uniqify = false) : string
{
    if ($old_name != $name) {
        // Check doesn't already exist
        $test = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogues', 'c_name', ['c_name' => $name]);
        if ($test !== null) {
            if ($uniqify) {
                $name .= '_' . uniqid('', false);
            } else {
                warn_exit(do_lang_tempcode('ALREADY_EXISTS', escape_html($name)));
            }
        }

        require_code('type_sanitisation');
        if (!is_alphanumeric($name)) {
            warn_exit(do_lang_tempcode('BAD_CODENAME'));
        }
    }

    $rows = $GLOBALS['SITE_DB']->query_select('catalogues', ['c_description', 'c_title'], ['c_name' => $old_name], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue'));
    }
    $myrow = $rows[0];
    $_title = $myrow['c_title'];
    $_description = $myrow['c_description'];

    // Edit
    $update_map = [
        'c_send_view_reports' => $send_view_reports,
        'c_display_type' => $display_type,
        'c_ecommerce' => $ecommerce,
        'c_categories_sort_order' => $categories_sort_order,
        'c_name' => $name,
        'c_notes' => $notes,
        'c_submit_points' => $submit_points,
        'c_default_review_freq' => $default_review_freq,
    ];
    $update_map += lang_remap('c_title', $_title, $title);
    $update_map += lang_remap_comcode('c_description', $_description, $description);
    if ($add_time !== null) {
        $update_map['c_add_date'] = $add_time;
    }
    $GLOBALS['SITE_DB']->query_update('catalogues', $update_map, ['c_name' => $old_name], '', 1);

    // If we're renaming, then we better change a load of references
    if ($name != $old_name) {
        $GLOBALS['SITE_DB']->query_update('catalogue_categories', ['c_name' => $name], ['c_name' => $old_name]);
        $GLOBALS['SITE_DB']->query_update('catalogue_fields', ['c_name' => $name], ['c_name' => $old_name]);
        $GLOBALS['SITE_DB']->query_update('catalogue_entries', ['c_name' => $name], ['c_name' => $old_name]);

        if (addon_installed('awards')) {
            $types = $GLOBALS['SITE_DB']->query_select('award_types', ['id'], ['a_content_type' => 'catalogue']);
            foreach ($types as $type) {
                $GLOBALS['SITE_DB']->query_update('award_archive', ['content_id' => $name], ['content_id' => $old_name, 'a_type_id' => $type['id']]);
            }
        }

        require_code('sitemap_xml');
        notify_sitemap_node_delete('_SEARCH:catalogues:index:' . $old_name);
    }

    // Update field references
    $GLOBALS['SITE_DB']->query_update('catalogue_fields', ['cf_type' => 'ck_' . $name], ['cf_type' => 'ck_' . $old_name]);
    $GLOBALS['SITE_DB']->query_update('catalogue_fields', ['cf_type' => 'cx_' . $name], ['cf_type' => 'cx_' . $old_name]);
    update_catalogue_content_ref('catalogue', $old_name, $name);

    delete_cache_entry('main_cc_embed');

    log_it('EDIT_CATALOGUE', $name, $title);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        if ($old_name != $name) { // We want special stability in catalogue addressing
            require_code('resource_fs');
            generate_resource_fs_moniker('catalogue', $name);
        }
    }

    if (substr($name, 0, 1) == '_') {
        persistent_cache_delete('CONTENT_TYPE_HAS_CUSTOM_FIELDS_CACHE');
    }

    delete_cache_entry('_field_type_selection');

    require_code('sitemap_xml');
    notify_sitemap_node_edit('_SEARCH:catalogues:index:' . $name);

    return $name;
}

/**
 * Delete a catalogue.
 *
 * @param  ID_TEXT $name The name of the catalogue
 */
function actual_delete_catalogue(string $name)
{
    // Delete lang
    $rows = $GLOBALS['SITE_DB']->query_select('catalogues', ['c_description', 'c_title'], ['c_name' => $name], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue'));
    }
    $myrow = $rows[0];

    // Delete anything involved (ha ha destruction!)
    do {
        $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLOW);

        $entries = collapse_1d_complexity('id', $GLOBALS['SITE_DB']->query_select('catalogue_entries', ['id'], ['c_name' => $name], '', 500));
        foreach ($entries as $entry) {
            actual_delete_catalogue_entry($entry);
        }

        cms_set_time_limit($old_limit);
    } while (array_key_exists(0, $entries));
    do {
        $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLOW);

        $categories = collapse_1d_complexity('id', $GLOBALS['SITE_DB']->query_select('catalogue_categories', ['id'], ['c_name' => $name], '', 30));
        foreach ($categories as $category) {
            actual_delete_catalogue_category($category, true);
        }

        cms_set_time_limit($old_limit);
    } while (array_key_exists(0, $categories));
    $fields = collapse_1d_complexity('id', $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['id'], ['c_name' => $name]));
    foreach ($fields as $field) {
        actual_delete_catalogue_field($field);
    }
    $GLOBALS['SITE_DB']->query_delete('catalogues', ['c_name' => $name], '', 1);
    $_title = $myrow['c_title'];
    $__title = get_translated_text($_title);
    delete_lang($myrow['c_title']);
    delete_lang($myrow['c_description']);
    $GLOBALS['SITE_DB']->query_delete('group_category_access', ['module_the_name' => 'catalogues_catalogue', 'category_name' => $name]);
    $GLOBALS['SITE_DB']->query_delete('group_privileges', ['module_the_name' => 'catalogues_catalogue', 'category_name' => $name]);

    // Update field references
    $GLOBALS['SITE_DB']->query_update('catalogue_fields', ['cf_type' => 'short_text'], ['cf_type' => 'ck_' . $name]);
    $GLOBALS['SITE_DB']->query_update('catalogue_fields', ['cf_type' => 'short_text'], ['cf_type' => 'cx_' . $name]);

    update_catalogue_content_ref('catalogue', $name, '');

    log_it('DELETE_CATALOGUE', $name, $__title);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        expunge_resource_fs_moniker('catalogue', $name);
    }

    if (substr($name, 0, 1) == '_') {
        persistent_cache_delete('CONTENT_TYPE_HAS_CUSTOM_FIELDS_CACHE');
    }

    require_code('sitemap_xml');
    notify_sitemap_node_delete('_SEARCH:catalogues:index:' . $name);

    if (addon_installed('ecommerce')) {
        require_code('ecommerce_permission_products');
        delete_prod_permission('catalogue', $name);
    }
}

/**
 * Edit a catalogue field.
 *
 * @param  AUTO_LINK $id The ID of the field
 * @param  ID_TEXT $c_name The name of the catalogue
 * @param  ?SHORT_TEXT $name The name of the field (null: do not change)
 * @param  ?LONG_TEXT $description Description for the field (null: do not change)
 * @param  integer $order The field order (the field order determines what order the fields are displayed within an entry)
 * @param  BINARY $defines_order Whether the field defines entry ordering
 * @param  BINARY $visible Whether the field is visible when an entry is viewed
 * @param  BINARY $sensitive Whether this field contains sensitive or personal data
 * @param  LONG_TEXT $default The default value for the field
 * @param  BINARY $required Whether the field is required
 * @param  BINARY $is_sortable Whether the field is sortable
 * @param  BINARY $include_in_main_search Whether the field is included in main search
 * @param  BINARY $allow_template_search Whether to allow template search
 * @param  BINARY $put_in_category Whether the field is to be shown in category views (not applicable for the list display type)
 * @param  BINARY $put_in_search Whether the field is to be shown in search views (not applicable for the list display type)
 * @param  SHORT_TEXT $options Field options
 * @param  ?ID_TEXT $type The field type (null: do not change)
 */
function actual_edit_catalogue_field(int $id, string $c_name, ?string $name, ?string $description, int $order, int $defines_order, int $visible, int $sensitive, string $default, int $required, int $is_sortable, int $include_in_main_search, int $allow_template_search, int $put_in_category = 1, int $put_in_search = 1, string $options = '', ?string $type = null) // You cannot edit a field type
{
    $rows = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['cf_description', 'cf_name', 'cf_type'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }
    $myrow = $rows[0];
    $_name = $myrow['cf_name'];
    $_description = $myrow['cf_description'];
    $_type = $myrow['cf_type'];

    $map = [
        'c_name' => $c_name,
        'cf_order' => $order,
        'cf_defines_order' => $defines_order,
        'cf_visible' => $visible,
        'cf_default' => $default,
        'cf_required' => $required,
        'cf_sensitive' => $sensitive,
        'cf_is_sortable' => $is_sortable,
        'cf_include_in_main_search' => $include_in_main_search,
        'cf_allow_template_search' => $allow_template_search,
        'cf_put_in_category' => $put_in_category,
        'cf_put_in_search' => $put_in_search,
        'cf_options' => $options,
    ];
    if ($type !== null) {
        $map['cf_type'] = $type;

        // FUDGE: We need to strip out some exotic syntax and convert to common-denominators
        if (($_type == 'video') && ($type == 'upload')) { // video -> upload
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_short', ' ');
        } elseif (($_type == 'video_multi') && ($type == 'upload_multi')) { // video_multi -> upload_multi
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_long', ' ');
        } elseif (($_type == 'upload') && ($type == 'picture')) { // upload -> picture
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_short', '::');
        } elseif (($_type == 'upload_multi') && ($type == 'picture_multi')) { // upload_multi -> picture_multi
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_long', '::');
        } elseif (($_type == 'upload') && ($type == 'video')) { // upload -> video
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_short', '::');
        } elseif (($_type == 'upload_multi') && ($type == 'video_multi')) { // upload_multi -> video_multi
            catalogue_field_strip_exotic_syntax($id, 'catalogue_efv_long', '::');
        }
    }
    if ($name !== null) {
        $map += lang_remap('cf_name', $_name, $name);
    }
    if ($description !== null) {
        $map += lang_remap('cf_description', $_description, $description);
    }

    $GLOBALS['SITE_DB']->query_update('catalogue_fields', $map, ['id' => $id], '', 1);
}

/**
 * Strip any extra syntax from a field, when converting to another format.
 *
 * @param  AUTO_LINK $id The ID of the field
 * @param  string $table The table
 * @param  string $delimiter Delimiter which separates special syntax
 */
function catalogue_field_strip_exotic_syntax(int $id, string $table, string $delimiter)
{
    cms_extend_time_limit(TIME_LIMIT_EXTEND__SLOW);

    $start = 0;
    do {
        $rows = $GLOBALS['SITE_DB']->query_select($table, ['id', 'cv_value'], ['cf_id' => $id], 'ORDER BY id', 100, $start);
        foreach ($rows as $row) {
            $value = $row['cv_value'];
            $lines = explode("\n", $value);
            foreach ($lines as &$line) {
                $bits = explode($delimiter, $line, 2);
                $line = $bits[0];
            }
            $value = implode("\n", $lines);
            $GLOBALS['SITE_DB']->query_update($table, ['cv_value' => $value], ['id' => $row['id']], '', 1);
        }
        $start += 100;
    } while (!empty($rows));
}

/**
 * Delete a catalogue field.
 *
 * @param  AUTO_LINK $id The ID of the field
 */
function actual_delete_catalogue_field(int $id)
{
    $rows = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['cf_name', 'cf_description', 'cf_type'], ['id' => $id]);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }
    $myrow = $rows[0];
    delete_lang($myrow['cf_name']);
    delete_lang($myrow['cf_description']);

    $GLOBALS['SITE_DB']->query_delete('catalogue_fields', ['id' => $id], '', 1);
}

/**
 * Add a catalogue category.
 *
 * @param  ID_TEXT $catalogue_name The codename of the catalogue the category is in
 * @param  mixed $title The title of this category (either language string map or string)
 * @param  mixed $description A description (either language string map or string)
 * @param  LONG_TEXT $notes Hidden notes pertaining to this category
 * @param  ?AUTO_LINK $parent_id The ID of this categories parent (null: a root category, or not a tree catalogue)
 * @param  URLPATH $rep_image The representative image for the category (blank: none)
 * @param  integer $move_days_lower The number of days before expiry (lower limit)
 * @param  integer $move_days_higher The number of days before expiry (higher limit)
 * @param  ?AUTO_LINK $move_target The expiry category (null: do not expire)
 * @param  ?TIME $add_date The add time (null: now)
 * @param  ?AUTO_LINK $id Force an ID (null: don't force an ID)
 * @param  ?SHORT_TEXT $meta_keywords Meta keywords for this resource (null: do not edit) (blank: implicit)
 * @param  ?LONG_TEXT $meta_description Meta description for this resource (null: do not edit) (blank: implicit)
 * @return AUTO_LINK The ID of the new category
 */
function actual_add_catalogue_category(string $catalogue_name, $title, $description, string $notes, ?int $parent_id, string $rep_image = '', int $move_days_lower = 30, int $move_days_higher = 60, ?int $move_target = null, ?int $add_date = null, ?int $id = null, ?string $meta_keywords = '', ?string $meta_description = '') : int
{
    if ($add_date === null) {
        $add_date = time();
    }

    if (is_string($title)) {
        require_code('global4');
        prevent_double_submit('ADD_CATALOGUE_CATEGORY', null, $title);
    }

    $map = [
        'cc_move_days_lower' => $move_days_lower,
        'cc_move_days_higher' => $move_days_higher,
        'cc_move_target_id' => $move_target,
        'rep_image' => $rep_image,
        'cc_add_date' => $add_date,
        'c_name' => $catalogue_name,
        'cc_notes' => $notes,
        'cc_parent_id' => $parent_id,
    ];
    if (!is_array($title)) {
        $map += insert_lang('cc_title', $title, 2);
    } else {
        $map += $title;
    }
    if (!is_array($description)) {
        $map += insert_lang_comcode('cc_description', $description, 2);
    } else {
        $map += $description;
    }
    if ($id !== null) {
        $map['id'] = $id;
    }
    $id = $GLOBALS['SITE_DB']->query_insert('catalogue_categories', $map, true);

    store_in_catalogue_cat_treecache($id, $parent_id); // *must* run before calculate_category_child_count_cache

    calculate_category_child_count_cache($parent_id);

    reorganise_uploads__catalogue_categories(['id' => $id]);

    log_it('ADD_CATALOGUE_CATEGORY', strval($id), get_translated_text($map['cc_title']));

    require_code('content2');
    if (($meta_keywords == '') && ($meta_description == '')) {
        if (!is_array($title)) {
            seo_meta_set_for_implicit('catalogue_category', strval($id), [$title, $description], $title);
        }
    } else {
        seo_meta_set_for_explicit('catalogue_category', strval($id), $meta_keywords, $meta_description);
    }

    if ($parent_id !== null) {
        require_code('notifications2');
        copy_notifications_to_new_child('catalogue_entry', strval($parent_id), strval($id));
    }

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        generate_resource_fs_moniker('catalogue_category', strval($id), null, null, true);
    }

    if (function_exists('get_member')) {
        require_code('member_mentions');
        dispatch_member_mention_notifications('catalogue_category', strval($id), get_member());
    }

    require_code('sitemap_xml');
    notify_sitemap_node_add('_SEARCH:catalogues:category:' . strval($id));

    return $id;
}

/**
 * Rebuild the efficient catalogue category tree structure ancestry cache.
 */
function rebuild_catalogue_cat_treecache()
{
    $GLOBALS['SITE_DB']->query_delete('catalogue_cat_treecache');
    $GLOBALS['SITE_DB']->query_delete('catalogue_childcountcache');

    push_query_limiting(false);

    $max = 1000;
    $start = 0;
    do {
        $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLOW);

        $rows = $GLOBALS['SITE_DB']->query_select('catalogue_categories', ['id', 'cc_parent_id'], [], '', $max, $start);

        foreach ($rows as $row) {
            store_in_catalogue_cat_treecache($row['id'], $row['cc_parent_id'], false);
        }

        $start += $max;

        cms_set_time_limit($old_limit);
    } while (!empty($rows));
    $start = 0;
    do {
        $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLOW);

        $rows = $GLOBALS['SITE_DB']->query_select('catalogue_categories', ['id', 'cc_parent_id'], [], '', $max, $start);

        foreach ($rows as $row) {
            calculate_category_child_count_cache($row['id'], false);
        }

        $start += $max;

        cms_set_time_limit($old_limit);
    } while (!empty($rows));
}

/**
 * Update the treecache for a catalogue category node.
 *
 * @param  AUTO_LINK $id The ID of the category
 * @param  ?AUTO_LINK $parent_id The ID of the parent category (null: no parent)
 * @param  boolean $cleanup_first Whether to delete any possible pre-existing records for the category first
 */
function store_in_catalogue_cat_treecache(int $id, ?int $parent_id, bool $cleanup_first = true)
{
    if ($cleanup_first) {
        $GLOBALS['SITE_DB']->query_delete('catalogue_cat_treecache', ['cc_id' => $id]);
    }

    // Self reference
    $GLOBALS['SITE_DB']->query_insert('catalogue_cat_treecache', [
        'cc_id' => $id,
        'cc_ancestor_id' => $id,
    ]);

    // Stored recursed referenced towards root
    while ($parent_id !== null) {
        $GLOBALS['SITE_DB']->query_insert('catalogue_cat_treecache', [
            'cc_id' => $id,
            'cc_ancestor_id' => $parent_id,
        ]);
        $parent_id = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_categories', 'cc_parent_id', ['id' => $parent_id]);
    }
}

/**
 * Update cache for a categories child counts.
 *
 * @param  ?AUTO_LINK $cat_id The ID of the category (null: skip, called by some code that didn't realise it didn't impact a tree parent)
 * @param  boolean $recursive_updates Whether to recurse up the tree to force recalculations on other categories (recommended, unless you are doing a complete rebuild)
 */
function calculate_category_child_count_cache(?int $cat_id, bool $recursive_updates = true)
{
    if ($cat_id === null) {
        return;
    }

    $GLOBALS['SITE_DB']->query_delete('catalogue_childcountcache', [
        'cc_id' => $cat_id,
    ], '', 1);

    $catalogue_name = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_categories', 'c_name', ['id' => $cat_id]);

    $num_rec_children = max(0, $GLOBALS['SITE_DB']->query_select_value('catalogue_cat_treecache', 'COUNT(*)', ['cc_ancestor_id' => $cat_id]) - 1);
    $num_rec_entries = $GLOBALS['SITE_DB']->query_select_value('catalogue_cat_treecache t JOIN ' . get_table_prefix() . 'catalogue_entries e ON e.cc_id=t.cc_id', 'COUNT(*)', ['ce_validated' => 1, 't.cc_ancestor_id' => $cat_id, 'c_name' => $catalogue_name/*important, else custom field cats could be included*/]);

    $GLOBALS['SITE_DB']->query_insert('catalogue_childcountcache', [
        'cc_id' => $cat_id,
        'c_num_rec_children' => $num_rec_children,
        'c_num_rec_entries' => $num_rec_entries,
    ]);

    if ($recursive_updates) {
        $parent_id = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_categories', 'cc_parent_id', ['id' => $cat_id]);
        if ($parent_id !== null) {
            calculate_category_child_count_cache($parent_id);
        }
    }
}

/**
 * Edit a catalogue category.
 *
 * @param  AUTO_LINK $id The ID of the category
 * @param  SHORT_TEXT $title The title of the category
 * @param  LONG_TEXT $description Description for the category
 * @param  LONG_TEXT $notes Admin notes
 * @param  ?AUTO_LINK $parent_id The ID of the parent category (null: no parent)
 * @param  SHORT_TEXT $meta_keywords Meta keywords for the category
 * @param  LONG_TEXT $meta_description Meta description for the category
 * @param  ?URLPATH $rep_image The representative image for the category (blank: none) (null: do not change)
 * @param  integer $move_days_lower The number of days before expiry (lower limit)
 * @param  integer $move_days_higher The number of days before expiry (higher limit)
 * @param  ?AUTO_LINK $move_target The expiry category (null: do not expire)
 * @param  ?TIME $add_time Add time (null: do not change)
 * @param  ?ID_TEXT $c_name The catalogue name (null: do not change)
 */
function actual_edit_catalogue_category(int $id, string $title, string $description, string $notes, ?int $parent_id, string $meta_keywords, string $meta_description, ?string $rep_image, int $move_days_lower, int $move_days_higher, ?int $move_target, ?int $add_time = null, ?string $c_name = null)
{
    $under_category_id = $parent_id;
    while (($under_category_id !== null) && ($under_category_id != INTEGER_MAGIC_NULL)) {
        if ($id == $under_category_id) {
            warn_exit(do_lang_tempcode('OWN_PARENT_ERROR', 'catalogue_category'));
        }
        $_under_category_id = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'cc_parent_id', ['id' => $under_category_id]);
        if ($under_category_id === $_under_category_id) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }
        $under_category_id = $_under_category_id;
    }

    $rows = $GLOBALS['SITE_DB']->query_select('catalogue_categories', ['cc_description', 'cc_title', 'c_name'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue_category'));
    }
    $myrow = $rows[0];
    $_title = $myrow['cc_title'];
    $_description = $myrow['cc_description'];

    store_in_catalogue_cat_treecache($id, $parent_id);

    $update_map = [
        'cc_move_days_lower' => $move_days_lower,
        'cc_move_days_higher' => $move_days_higher,
        'cc_move_target_id' => $move_target,
        'cc_notes' => $notes,
        'cc_parent_id' => $parent_id,
    ];
    $update_map += lang_remap('cc_title', $_title, $title);
    $update_map += lang_remap_comcode('cc_description', $_description, $description);

    if ($rep_image !== null) {
        $update_map['rep_image'] = $rep_image;
        require_code('files2');
        delete_upload('uploads/repimages', 'catalogue_categories', 'rep_image', 'id', $id, $rep_image);
    }

    if ($add_time !== null) {
        $update_map['cc_add_date'] = $add_time;
    }

    if ($c_name !== null) { // Moving to a different catalogue
        $update_map['c_name'] = $c_name;
        $GLOBALS['SITE_DB']->query_update('catalogue_entries', ['c_name' => $c_name], ['cc_id' => $id]);
        $sub_ids = $GLOBALS['SITE_DB']->query_select('catalogue_cat_treecache', ['cc_id'], ['cc_ancestor_id' => $id]);
        foreach ($sub_ids as $_sub_id) {
            $sub_id = $_sub_id['cc_id'];
            $GLOBALS['SITE_DB']->query_update('catalogue_categories', ['c_name' => $c_name], ['id' => $sub_id], '', 1);
        }
    }

    $old_parent_id = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'cc_parent_id', ['id' => $id]);

    $GLOBALS['SITE_DB']->query_update('catalogue_categories', $update_map, ['id' => $id], '', 1);

    require_code('urls2');
    suggest_new_idmoniker_for('catalogues', 'category', strval($id), '', $title);

    require_code('content2');
    seo_meta_set_for_explicit('catalogue_category', strval($id), $meta_keywords, $meta_description);

    if ($old_parent_id !== $parent_id) {
        calculate_category_child_count_cache($old_parent_id);
        calculate_category_child_count_cache($parent_id);
    }

    reorganise_uploads__catalogue_categories(['id' => $id]);

    log_it('EDIT_CATALOGUE_CATEGORY', strval($id), $title);

    delete_cache_entry('main_cc_embed');

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        generate_resource_fs_moniker('catalogue_category', strval($id));
    }

    require_code('sitemap_xml');
    notify_sitemap_node_edit('_SEARCH:catalogues:category:' . strval($id));
}

/**
 * Delete a catalogue category.
 *
 * @param  AUTO_LINK $id The ID of the category
 * @param  boolean $deleting_all Whether we're deleting everything under the category; if FALSE we will actively reassign child categories and entries up a level (if tree) or deletes (if not tree)
 */
function actual_delete_catalogue_category(int $id, bool $deleting_all = false)
{
    // Info about our category
    $rows = $GLOBALS['SITE_DB']->query_select('catalogue_categories c LEFT JOIN ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'catalogues x ON c.c_name=x.c_name', ['c_is_tree', 'c.c_name', 'cc_description', 'cc_title', 'cc_parent_id'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue_category'));
    }
    $myrow = $rows[0];

    // If we aren't deleting the entire catalogue, make sure we don't delete the root category
    if ((!$deleting_all) && ($myrow['c_is_tree'] == 1)) {
        $root_category = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'MIN(id)', ['c_name' => $myrow['c_name'], 'cc_parent_id' => null]);
        if ($id == $root_category) {
            warn_exit(do_lang_tempcode('CATALOGUE_NO_DELETE_ROOT'));
        }
    }

    $GLOBALS['SITE_DB']->query_delete('catalogue_cat_treecache', ['cc_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_childcountcache', ['cc_id' => $id]);

    require_code('files2');
    delete_upload('uploads/repimages', 'catalogue_categories', 'rep_image', 'id', $id);

    if (!$deleting_all) { // If not deleting the whole catalogue
        if ($myrow['c_is_tree'] == 1) {
            $old_limit = cms_disable_time_limit();

            // If we're in a tree...

            $GLOBALS['SITE_DB']->query_update('catalogue_categories', ['cc_parent_id' => $myrow['cc_parent_id']], ['cc_parent_id' => $id]);
            $GLOBALS['SITE_DB']->query_update('catalogue_entries', ['cc_id' => $myrow['cc_parent_id']], ['cc_id' => $id]);

            cms_set_time_limit($old_limit);
        } else { // If we're not in a tree catalogue we can't move them, we have to delete
            // If we're not in a tree...

            $GLOBALS['SITE_DB']->query_delete('catalogue_categories', ['cc_parent_id' => $id]); // Does nothing, in theory, as it's not a tree!
            $start = 0;
            do {
            $old_limit = cms_set_time_limit(TIME_LIMIT_EXTEND__SLOW);

                send_http_output_ping();

                $entries = $GLOBALS['SITE_DB']->query_select('catalogue_entries', ['id'], ['cc_id' => $id], '', 500, $start);
                foreach ($entries as $entry) {
                    actual_delete_catalogue_entry($entry['id']);
                }
                $start += 500;

                cms_set_time_limit($old_limit);
            } while (!empty($entries));
        }

        $GLOBALS['SITE_DB']->query_update('catalogue_categories', ['cc_move_target_id' => null], ['cc_move_target_id' => $id]);
    }

    update_catalogue_content_ref('catalogue_category', strval($id), '');

    require_code('content2');
    seo_meta_erase_storage('catalogue_category', strval($id));

    $_title = get_translated_text($myrow['cc_title']);

    // Delete lang
    delete_lang($myrow['cc_title']);
    delete_lang($myrow['cc_description']);

    $old_parent_id = $myrow['cc_parent_id'];

    $GLOBALS['SITE_DB']->query_delete('catalogue_categories', ['id' => $id], '', 1);

    $GLOBALS['SITE_DB']->query_delete('group_category_access', ['module_the_name' => 'catalogues_category', 'category_name' => strval($id)]);
    $GLOBALS['SITE_DB']->query_delete('group_privileges', ['module_the_name' => 'catalogues_category', 'category_name' => strval($id)]);

    $GLOBALS['SITE_DB']->query_update('url_id_monikers', ['m_deprecated' => 1], ['m_resource_page' => 'catalogues', 'm_resource_type' => 'category', 'm_resource_id' => strval($id)]);

    calculate_category_child_count_cache($old_parent_id);

    require_code('uploads2');
    clean_empty_upload_directories('uploads/repimages');

    log_it('DELETE_CATALOGUE_CATEGORY', strval($id), $_title);

    delete_cache_entry('main_cc_embed');

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        expunge_resource_fs_moniker('catalogue_category', strval($id));
    }

    require_code('sitemap_xml');
    notify_sitemap_node_delete('_SEARCH:catalogues:category:' . strval($id));

    if (addon_installed('ecommerce')) {
        require_code('ecommerce_permission_products');
        delete_prod_permission('catalogue_category', strval($id));
    }
}

/**
 * Adds an entry to the specified catalogue.
 *
 * @param  AUTO_LINK $category_id The ID of the category that the entry is in
 * @param  BINARY $validated Whether the entry has been validated
 * @param  LONG_TEXT $notes Hidden notes pertaining to the entry
 * @param  BINARY $allow_rating Whether the entry may be rated
 * @param  SHORT_INTEGER $allow_comments Whether comments are allowed (0=no, 1=yes, 2=review style)
 * @param  BINARY $allow_trackbacks Whether the entry may be trackbacked
 * @param  array $map A map of field IDs, to values, that defines the entries settings
 * @param  ?TIME $time The time the entry was added (null: now)
 * @param  ?MEMBER $submitter The entries submitter (null: current user)
 * @param  ?TIME $edit_date The edit time (null: never)
 * @param  integer $views The number of views
 * @param  ?AUTO_LINK $id Force an ID (null: don't force an ID)
 * @param  ?SHORT_TEXT $meta_keywords Meta keywords for this resource (null: do not edit) (blank: implicit)
 * @param  ?LONG_TEXT $meta_description Meta description for this resource (null: do not edit) (blank: implicit)
 * @return AUTO_LINK The ID of the newly added entry
 */
function actual_add_catalogue_entry(int $category_id, int $validated, string $notes, int $allow_rating, int $allow_comments, int $allow_trackbacks, array $map, ?int $time = null, ?int $submitter = null, ?int $edit_date = null, int $views = 0, ?int $id = null, ?string $meta_keywords = '', ?string $meta_description = '') : int
{
    if ($time === null) {
        $time = time();
    }
    if ($submitter === null) {
        $submitter = get_member();
    }

    $catalogue_name = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'c_name', ['id' => $category_id]);
    $catalogue_title = get_translated_text($GLOBALS['SITE_DB']->query_select_value('catalogues', 'c_title', ['c_name' => $catalogue_name]));
    $_fields = list_to_map('id', $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['id', 'cf_type'], ['c_name' => $catalogue_name]));
    $fields = collapse_2d_complexity('id', 'cf_type', $_fields);

    require_code('comcode_check');
    require_code('fields');

    @ignore_user_abort(true);

    if (!addon_installed('validation')) {
        $validated = 1;
    }
    $imap = ['c_name' => $catalogue_name, 'ce_edit_date' => $edit_date, 'cc_id' => $category_id, 'ce_last_moved' => time(), 'ce_submitter' => $submitter, 'ce_add_date' => $time, 'ce_views' => $views, 'ce_views_prior' => $views, 'ce_validated' => $validated, 'notes' => $notes, 'allow_rating' => $allow_rating, 'allow_comments' => $allow_comments, 'allow_trackbacks' => $allow_trackbacks];
    if ($id !== null) {
        $imap['id'] = $id;
    }
    $val = null;
    foreach ($map as $field_id => $val) {
        $type = $fields[$field_id];

        $ob = get_fields_hook($type);
        list($raw_type) = $ob->get_field_value_row_bits($_fields[$field_id]);

        if (strpos($raw_type, '_trans') !== false) {
            check_comcode($val);
        }
    }

    $title = null;
    foreach ($map as $field_id => $val) {
        if ($val == STRING_MAGIC_NULL) {
            $val = '';
        }

        if ($title === null) {
            $title = $val;
            require_code('global4');
            prevent_double_submit('ADD_CATALOGUE_ENTRY', null, $title);
        }
    }

    if ($title === null) {
        $title = do_lang('_UNKNOWN');
    }

    static $done_one_posting_field = false;

    $id = $GLOBALS['SITE_DB']->query_insert('catalogue_entries', $imap, true);

    $seo_source_map = [];

    foreach ($map as $field_id => $val) {
        if ($val == STRING_MAGIC_NULL) {
            $val = '';
        }

        $type = $fields[$field_id];

        $ob = get_fields_hook($type);
        $seo_source_map[] = $ob->get_seo_source_map($val, $field_id, 'catalogue_entry', strval($id));
        list($raw_type, , $sup_table_name) = $ob->get_field_value_row_bits($_fields[$field_id]);

        $smap = [
            'cf_id' => $field_id,
            'ce_id' => $id,
        ];

        if (strpos($raw_type, '_trans') !== false) {
            if (($type == 'posting_field') && (!$done_one_posting_field)) {
                $done_one_posting_field = true;
                require_code('attachments2');
                $smap += insert_lang_comcode_attachments('cv_value', 3, $val, 'catalogue_entry', strval($id));
            } else {
                $smap += insert_lang_comcode('cv_value', $val, 3);
            }
        } else {
            if ($sup_table_name == 'short') {
                $val = cms_mb_substr($val, 0, 255);
            }

            if ($sup_table_name == 'float') {
                $smap['cv_value'] = (($val === null) || ($val == '')) ? null : floatval($val);
            } elseif ($sup_table_name == 'integer') {
                $smap['cv_value'] = (($val === null) || ($val == '')) ? null : intval($val);
            } else {
                $smap['cv_value'] = $val;
            }
        }
        $GLOBALS['SITE_DB']->query_insert('catalogue_efv_' . $sup_table_name, $smap);
    }

    require_code('content2');
    if (($meta_keywords == '') && ($meta_description == '')) {
        $seo_source_map__specific = get_value('catalogue_seo_source_map__' . $catalogue_name);
        if ($seo_source_map__specific !== null) {
            $seo_source_map = [];
            foreach (explode(',', $seo_source_map__specific) as $_map_source) {
                if (substr($seo_source_map__specific, -1) == '!') {
                    $must_use = true;
                    $seo_source_map__specific = substr($seo_source_map__specific, 0, strlen($seo_source_map__specific) - 1);
                } else {
                    $must_use = false;
                }
                if (isset($map[intval($seo_source_map__specific)])) {
                    $seo_source_map[] = [$map[intval($seo_source_map__specific)], $must_use];
                }
            }
        }
        foreach ($fields as $field_id => $cf_type) {
            if (($cf_type == 'long_trans') || ($cf_type == 'long_text') || ($cf_type == 'posting_field')) {
                $meta_description = isset($map[$field_id]) ? $map[$field_id] : '';
                break;
            }
        }

        seo_meta_set_for_implicit('catalogue_entry', strval($id), $seo_source_map, $meta_description);
    } else {
        seo_meta_set_for_explicit('catalogue_entry', strval($id), $meta_keywords, $meta_description);
    }

    calculate_category_child_count_cache($category_id);

    if ($catalogue_name[0] != '_') {
        if ($validated == 1) {
            if (addon_installed('content_privacy')) {
                require_code('content_privacy');
                $privacy_limits = privacy_limits_for('catalogue_entry', strval($id));
            } else {
                $privacy_limits = null;
            }

            require_lang('catalogues');
            require_code('notifications');
            $subject = do_lang('CATALOGUE_ENTRY_NOTIFICATION_MAIL_SUBJECT', get_site_name(), strip_comcode($title), [$catalogue_title]);
            $self_url = build_url(['page' => 'catalogues', 'type' => 'entry', 'id' => $id], get_module_zone('catalogues'), [], false, false, true);
            $mail = do_notification_lang('CATALOGUE_ENTRY_NOTIFICATION_MAIL', comcode_escape(get_site_name()), comcode_escape(strip_comcode($title)), [comcode_escape($self_url->evaluate()), comcode_escape($catalogue_title)]);
            dispatch_notification('catalogue_entry__' . $catalogue_name, strval($category_id), $subject, $mail, $privacy_limits);
        }

        log_it('ADD_CATALOGUE_ENTRY', strval($id), $title);

        if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
            require_code('resource_fs');
            generate_resource_fs_moniker('catalogue_entry', strval($id), null, null, true);
        }
    }

    // Run form handlers for catalogues
    $hook_obs = find_all_hook_obs('form_handlers', 'catalogue_entry', 'Hook_form_handlers_catalogue_entry_');
    foreach ($hook_obs as $hook => $ob) {
        if (!method_exists($ob, 'add')) {
            continue;
        }
        $ob->add($id, $category_id, $catalogue_name, $validated, $notes, $allow_comments, $allow_trackbacks, $map, $time, $submitter, $edit_date, $views, $meta_keywords, $meta_description);
    }

    reorganise_uploads__catalogue_entries(['ce_id' => $id]);

    delete_cache_entry('main_cc_embed');

    require_code('member_mentions');
    dispatch_member_mention_notifications('catalogue_entry', strval($id), $submitter);

    if ($validated == 1) {
        require_code('sitemap_xml');
        notify_sitemap_node_add('_SEARCH:catalogues:entry:' . strval($id));
    }

    @ignore_user_abort(false);

    return $id;
}

/**
 * Edit the specified catalogue entry.
 *
 * @param  AUTO_LINK $id The ID of the entry being edited
 * @param  AUTO_LINK $category_id The ID of the category that the entry is in
 * @param  BINARY $validated Whether the entry has been validated
 * @param  LONG_TEXT $notes Hidden notes pertaining to the entry
 * @param  BINARY $allow_rating Whether the entry may be rated
 * @param  SHORT_INTEGER $allow_comments Whether comments are allowed (0=no, 1=yes, 2=review style)
 * @param  BINARY $allow_trackbacks Whether the entry may be trackbacked
 * @param  array $map A map of field IDs, to values, that defines the entries settings
 * @param  ?SHORT_TEXT $meta_keywords Meta keywords for this resource (null: do not edit)
 * @param  ?LONG_TEXT $meta_description Meta description for this resource (null: do not edit)
 * @param  ?TIME $edit_time Edit time (null: either means current time, or if $null_is_literal, means reset to to null)
 * @param  ?TIME $add_time Add time (null: do not change)
 * @param  ?integer $views Number of views (null: do not change)
 * @param  ?MEMBER $submitter Submitter (null: do not change)
 * @param  boolean $null_is_literal Determines whether some nulls passed mean 'use a default' or literally mean 'set to null
 */
function actual_edit_catalogue_entry(int $id, int $category_id, int $validated, string $notes, int $allow_rating, int $allow_comments, int $allow_trackbacks, array $map, ?string $meta_keywords = '', ?string $meta_description = '', ?int $edit_time = null, ?int $add_time = null, ?int $views = null, ?int $submitter = null, bool $null_is_literal = false)
{
    if ($edit_time === null) {
        $edit_time = $null_is_literal ? null : time();
    }

    $catalogue_name = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'c_name', ['id' => $id]);
    $catalogue_title = get_translated_text($GLOBALS['SITE_DB']->query_select_value('catalogues', 'c_title', ['c_name' => $catalogue_name]));
    $_fields = list_to_map('id', $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['id', 'cf_type'], ['c_name' => $catalogue_name]));
    $fields = collapse_2d_complexity('id', 'cf_type', $_fields);

    $original_submitter = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_entries', 'ce_submitter', ['id' => $id]);
    if ($original_submitter === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue_entry'));
    }

    $old_category_id = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'cc_id', ['id' => $id]);

    if (!addon_installed('validation')) {
        $validated = 1;
    }

    require_code('submit');
    $was_validated = content_validated('catalogue_entry', strval($id));
    $just_validated = (!$was_validated) && ($validated == 1);
    if ($just_validated) {
        send_content_validated_notification('catalogue_entry', strval($id));
        $username = $GLOBALS['FORUM_DRIVER']->get_username(get_member());
        log_it('VALIDATE_CATALOGUE_ENTRY', strval($id), $username);
    }

    $update_map = [
        'cc_id' => $category_id,
        'ce_validated' => $validated,
        'notes' => $notes,
        'allow_rating' => $allow_rating,
        'allow_comments' => $allow_comments,
        'allow_trackbacks' => $allow_trackbacks,
    ];

    $update_map['ce_edit_date'] = $edit_time;
    if ($add_time !== null) {
        $update_map['ce_add_date'] = $add_time;
    }
    if ($views !== null) {
        $update_map['ce_views'] = $views;
    }
    if ($submitter !== null) {
        $update_map['ce_submitter'] = $submitter;
    }

    @ignore_user_abort(true);

    static $done_one_posting_field = false;

    $GLOBALS['SITE_DB']->query_update('catalogue_entries', $update_map, ['id' => $id], '', 1);

    require_code('fields');
    $title = null;
    foreach ($map as $field_id => $val) {
        if ($title === null) {
            $title = $val;
        }

        $type = $fields[$field_id];

        $ob = get_fields_hook($type);
        list(, , $sup_table_name) = $ob->get_field_value_row_bits($_fields[$field_id]);

        $smap = [];

        if (substr($sup_table_name, -6) == '_trans') {
            $_val = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_efv_' . $sup_table_name, 'cv_value', ['cf_id' => $field_id, 'ce_id' => $id]);
            if ($_val === null) {
                if (($type == 'posting_field') && (!$done_one_posting_field)) {
                    $done_one_posting_field = true;
                    require_code('attachments2');
                    require_code('attachments3');
                    $smap += insert_lang_comcode_attachments('cv_value', 3, $val, 'catalogue_entry', strval($id), null, false, $original_submitter);
                } else {
                    $smap += insert_lang_comcode('cv_value', $val, 3);
                }
            } else {
                if (($type == 'posting_field') && (!$done_one_posting_field)) {
                    $done_one_posting_field = true;
                    require_code('attachments2');
                    require_code('attachments3');
                    $smap += update_lang_comcode_attachments('cv_value', $_val, $val, 'catalogue_entry', strval($id), null, $original_submitter);
                } else {
                    $smap += lang_remap_comcode('cv_value', $_val, $val);
                }
            }
        } else {
            if ($sup_table_name == 'short') {
                $val = cms_mb_substr($val, 0, 255);
            }

            if ($sup_table_name == 'float') {
                $smap['cv_value'] = (($val === null) || ($val == '')) ? null : floatval($val);
            } elseif ($sup_table_name == 'integer') {
                $smap['cv_value'] = (($val === null) || ($val == '')) ? null : intval($val);
            } else {
                $smap['cv_value'] = $val;
            }
        }

        $cnt_field_rows_already = $GLOBALS['SITE_DB']->query_select_value('catalogue_efv_' . $sup_table_name, 'COUNT(*)', ['cf_id' => $field_id, 'ce_id' => $id]);
        if ($cnt_field_rows_already == 0) {
            $GLOBALS['SITE_DB']->query_insert('catalogue_efv_' . $sup_table_name, $smap + ['cf_id' => $field_id, 'ce_id' => $id]); // Corruption, doing a repair
        } else {
            $GLOBALS['SITE_DB']->query_update('catalogue_efv_' . $sup_table_name, $smap, ['cf_id' => $field_id, 'ce_id' => $id], '', 1);
        }
    }

    require_code('urls2');
    suggest_new_idmoniker_for('catalogues', 'entry', strval($id), '', strip_comcode($title));

    require_code('content2');
    seo_meta_set_for_explicit('catalogue_entry', strval($id), $meta_keywords, $meta_description);

    $self_url = build_url(['page' => 'catalogues', 'type' => 'entry', 'id' => $id], get_module_zone('catalogues'), [], false, false, true);

    if (($category_id != $old_category_id) || ($was_validated != ($validated == 1))) {
        calculate_category_child_count_cache($category_id);
        if ($category_id != $old_category_id) {
            calculate_category_child_count_cache($old_category_id);
        }
    }

    delete_cache_entry('main_cc_embed');

    if ($catalogue_name[0] != '_') {
        log_it('EDIT_CATALOGUE_ENTRY', strval($id), $title);

        if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
            require_code('resource_fs');
            generate_resource_fs_moniker('catalogue_entry', strval($id));
        }

        if ($just_validated) {
            if (addon_installed('content_privacy')) {
                require_code('content_privacy');
                $privacy_limits = privacy_limits_for('catalogue_entry', strval($id));
            } else {
                $privacy_limits = null;
            }

            require_lang('catalogues');
            require_code('notifications');
            $subject = do_lang('CATALOGUE_ENTRY_NOTIFICATION_MAIL_SUBJECT', get_site_name(), strip_comcode($title), [$catalogue_title]);
            $mail = do_notification_lang('CATALOGUE_ENTRY_NOTIFICATION_MAIL', comcode_escape(get_site_name()), comcode_escape(strip_comcode($title)), [comcode_escape($self_url->evaluate()), comcode_escape($catalogue_title)]);
            dispatch_notification('catalogue_entry__' . $catalogue_name, strval($category_id), $subject, $mail, $privacy_limits);
        }
    }

    // Run form handlers for catalogues
    $hook_obs = find_all_hook_obs('form_handlers', 'catalogue_entry', 'Hook_form_handlers_catalogue_entry_');
    foreach ($hook_obs as $hook => $ob) {
        if (!method_exists($ob, 'edit')) {
            continue;
        }
        $ob->edit($id, $category_id, $catalogue_name, $validated, $notes, $allow_rating, $allow_comments, $allow_trackbacks, $map, $meta_keywords, $meta_description, $edit_time, $add_time, $views, $submitter, $null_is_literal);
    }

    reorganise_uploads__catalogue_entries(['ce_id' => $id]);

    require_code('feedback');
    update_spacer_post(
        $allow_comments != 0,
        'catalogues',
        strval($id),
        $self_url,
        $title,
        process_overridden_comment_forum('catalogue_entry__' . $catalogue_name, strval($id), strval($category_id), strval($old_category_id))
    );

    require_code('sitemap_xml');
    if ($validated == 1) {
        notify_sitemap_node_edit('_SEARCH:catalogues:entry:' . strval($id));
    } else {
        notify_sitemap_node_delete('_SEARCH:catalogues:entry:' . strval($id));
    }

    @ignore_user_abort(false);
}

/**
 * Delete a catalogue entry.
 *
 * @param  AUTO_LINK $id The ID of the entry to delete
 */
function actual_delete_catalogue_entry(int $id)
{
    $old_category_id = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_entries', 'cc_id', ['id' => $id]);
    if ($old_category_id === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'catalogue_entry'));
    }

    $catalogue_name = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'c_name', ['id' => $id]);

    @ignore_user_abort(true);

    require_code('fields');
    require_code('catalogues');
    $fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['*'], ['c_name' => $catalogue_name]);
    $title = null;
    foreach ($fields as $i => $field) {
        $object = get_fields_hook($field['cf_type']);
        list(, , $storage_type) = $object->get_field_value_row_bits($field);
        $value = _get_catalogue_entry_field($field['id'], $id, $storage_type);
        if (method_exists($object, 'cleanup')) {
            $object->cleanup($value);
        }
        if ($title === null) {
            $target = [];
            _resolve_catalogue_entry_field($field, $id, null, $target, $i);
            $title = $target['effective_value_pure'];
        }
    }

    $lang1 = $GLOBALS['SITE_DB']->query_select('catalogue_efv_long_trans', ['cv_value'], ['ce_id' => $id]);
    $lang2 = $GLOBALS['SITE_DB']->query_select('catalogue_efv_short_trans', ['cv_value'], ['ce_id' => $id]);
    $lang = array_merge($lang1, $lang2);
    foreach ($lang as $lang_to_delete) {
        if (true) { // Always do this just in case it is for attachments
            require_code('attachments2');
            require_code('attachments3');
            delete_lang_comcode_attachments($lang_to_delete['cv_value'], 'catalogue_entry', strval($id));
        } else {
            delete_lang($lang_to_delete['cv_value']);
        }
    }

    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_long_trans', ['ce_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_short_trans', ['ce_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_long', ['ce_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_short', ['ce_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_float', ['ce_id' => $id]);
    $GLOBALS['SITE_DB']->query_delete('catalogue_efv_integer', ['ce_id' => $id]);

    $GLOBALS['SITE_DB']->query_delete('catalogue_entries', ['id' => $id], '', 1);
    $GLOBALS['SITE_DB']->query_delete('trackbacks', ['trackback_for_type' => 'catalogues', 'trackback_for_id' => strval($id)]);
    $GLOBALS['SITE_DB']->query_delete('rating', ['rating_for_type' => 'catalogues', 'rating_for_id' => strval($id)]);
    require_code('notifications');
    delete_all_notifications_on('comment_posted', 'catalogues_' . strval($id));

    update_catalogue_content_ref('ck_' . $catalogue_name, strval($id), '');
    update_catalogue_content_ref('catalogue_entry', strval($id), '');

    require_code('content2');
    seo_meta_erase_storage('catalogue_entry', strval($id));

    calculate_category_child_count_cache($old_category_id);

    $GLOBALS['SITE_DB']->query_update('url_id_monikers', ['m_deprecated' => 1], ['m_resource_page' => 'catalogues', 'm_resource_type' => 'entry', 'm_resource_id' => strval($id)]);

    delete_cache_entry('main_cc_embed');

    require_code('uploads2');
    clean_empty_upload_directories('uploads/catalogues');

    if ($catalogue_name[0] != '_') {
        log_it('DELETE_CATALOGUE_ENTRY', strval($id), $title);
    }

    // Run form handlers for catalogues
    $hook_obs = find_all_hook_obs('form_handlers', 'catalogue_entry', 'Hook_form_handlers_catalogue_entry_');
    foreach ($hook_obs as $hook => $ob) {
        if (!method_exists($ob, 'delete')) {
            continue;
        }
        $ob->delete($id, $old_category_id, $catalogue_name);
    }

    if (addon_installed('search')) {
        require_code('database_search');
        Fast_custom_index::delete_from_index($GLOBALS['SITE_DB'], 'ce_fulltext_index', ['i_catalogue_entry_id' => $id]);
    }

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        expunge_resource_fs_moniker('catalogue_entry', strval($id));
    }

    require_code('sitemap_xml');
    notify_sitemap_node_delete('_SEARCH:catalogues:entry:' . strval($id));

    @ignore_user_abort(false);
}

/**
 * Reorganise the catalogue category uploads.
 *
 * @param  array $where Limit reorganisation to rows matching this WHERE map
 * @param  boolean $tolerate_errors Whether to tolerate missing files (false = give an error)
 */
function reorganise_uploads__catalogue_categories(array $where = [], bool $tolerate_errors = false)
{
    require_code('uploads2');
    reorganise_uploads('catalogue_category', 'uploads/repimages', 'rep_image', $where, true, $tolerate_errors);
}

/**
 * Reorganise the catalogue entry uploads.
 *
 * @param  array $where Limit reorganisation to rows matching this WHERE map
 * @param  boolean $tolerate_errors Whether to tolerate missing files (false = give an error)
 */
function reorganise_uploads__catalogue_entries(array $where = [], bool $tolerate_errors = false)
{
    require_code('uploads2');
    $fake_cma_info = [
        'table' => 'catalogue_efv_short d',
        'table_extended' => 'catalogue_efv_short d JOIN ' . get_table_prefix() . 'catalogue_entries e ON e.id=d.ce_id JOIN ' . get_table_prefix() . 'catalogue_fields f ON f.id=d.cf_id',
        'db' => $GLOBALS['SITE_DB'],
        'id_field' => 'd.id',
        'parent_category_field' => 'cc_id',
        'title_field' => null,
        'title_field_dereference' => null,
        'parent_spec__table_name' => 'catalogue_categories',
        'parent_spec__field_name' => 'id',
        'parent_spec__parent_name' => 'cc_parent_id',
    ];
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'upload'], false, $tolerate_errors, $fake_cma_info);
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'picture'], false, $tolerate_errors, $fake_cma_info);
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'video'], false, $tolerate_errors, $fake_cma_info);
    $fake_cma_info['table'] = 'catalogue_efv_long';
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'upload_multi'], false, $tolerate_errors, $fake_cma_info);
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'picture_multi'], false, $tolerate_errors, $fake_cma_info);
    reorganise_uploads('catalogue_entry', 'uploads/catalogues', 'cv_value', $where + ['cf_type' => 'video_multi'], false, $tolerate_errors, $fake_cma_info);
}
