Proyectos de Subversion Moodle

Rev

Autoría | Ultima modificación | Ver Log |

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

namespace core_question\local\bank;

use cm_info;
use context;
use context_course;
use core\task\manager;
use moodle_url;
use stdClass;

defined('MOODLE_INTERNAL') || die();

require_once($CFG->dirroot . '/lib/questionlib.php');
require_once($CFG->dirroot . '/course/modlib.php');

/**
 * Helper class for qbank sharing.
 *
 * @package    core_question
 * @copyright  2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
 * @author     Simon Adams <simon.adams@catalyst-eu.net>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class question_bank_helper {
    /** @var string the type of qbank module that users create */
    public const TYPE_STANDARD = 'standard';

    /**
     * The type of shared bank module that the system creates.
     * These are created in course restores when no target context can be found,
     * and also for when a question category cannot be deleted safely due to questions being in use.
     *
     * @var string
     */
    public const TYPE_SYSTEM = 'system';

    /** @var string The type of shared bank module that the system creates for previews. Not used for any other purpose. */
    public const TYPE_PREVIEW = 'preview';

    /** @var array Shared bank types */
    public const SHARED_TYPES = [self::TYPE_STANDARD, self::TYPE_SYSTEM, self::TYPE_PREVIEW];

    /**
     * User preferences record key to store recently viewed question banks.
     */
    protected const RECENTLY_VIEWED = 'recently_viewed_open_banks';

    /**
     * Category delimiter used by the SQL to group concatenate question category data i.e.
     * category_id<->category_name<->context_id.
     */
    private const CATEGORY_DELIMITER = '<->';

    /**
     * Category separator used by the SQL for group concatenation of those category triplets
     * from above.
     */
    private const CATEGORY_SEPARATOR = '<,>';

    /**
     * Maximum length for the question bank name database field.
     */
    public const BANK_NAME_MAX_LENGTH = 255;

    /**
     * Modules that share questions via FEATURE_PUBLISHES_QUESTIONS.
     *
     * @return array
     */
    public static function get_activity_types_with_shareable_questions(): array {
        static $sharedmods;

        if (!empty($sharedmods)) {
            return $sharedmods;
        }

        $manager = \core_plugin_manager::instance();
        $plugins = $manager->get_enabled_plugins('mod');

        $sharedmods = array_filter(
            array_keys($plugins),
            static fn ($plugin) => plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&
                question_module_uses_questions($plugin)
        );

        return array_values($sharedmods);
    }

    /**
     * Get module types that do not share questions. They will have FEATURE_USES_QUESTIONS set to false or won't have it defined.
     *
     * @return array
     */
    public static function get_activity_types_with_private_questions(): array {
        static $privatemods;

        if (!empty($privatemods)) {
            return $privatemods;
        }

        $manager = \core_plugin_manager::instance();
        $plugins = $manager->get_enabled_plugins('mod');

        $privatemods = array_filter(
            array_keys($plugins),
            static fn ($plugin) => !plugin_supports('mod', $plugin, FEATURE_PUBLISHES_QUESTIONS) &&
                question_module_uses_questions($plugin)
        );

        return array_values($privatemods);
    }

    /**
     * Get records for activity modules that do publish questions, and optionally get their question categories too.
     *
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
     * @param array $notincourseids array of course ids where you do not want instances included.
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
     * @param bool $getcategories optionally return the categories belonging to these banks.
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
     * it will only be included if the other parameters allow it.
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
     *      parameters that will get banks across multiple contexts.
     * @param string $search Optional term to search question bank instances by name
     * @param int $limit The number of results to return (default 0 = no limit)
     * @return stdClass[]
     */
    public static function get_activity_instances_with_shareable_questions(
        array $incourseids = [],
        array $notincourseids = [],
        array $havingcap = [],
        bool $getcategories = false,
        int $currentbankid = 0,
        ?context $filtercontext = null,
        string $search = '',
        int $limit = 0,
    ): array {
        return self::get_bank_instances(true,
            $incourseids,
            $notincourseids,
            $getcategories,
            $currentbankid,
            $havingcap,
            $filtercontext,
            $search,
            $limit,
        );
    }

    /**
     * Get records for activity modules that don't publish questions, and optionally get their question categories too.
     *
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
     * @param array $notincourseids array of course ids where you do not want instances included.
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
     * @param bool $getcategories optionally return the categories belonging to these banks.
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
     * it will only be included if the other parameters allow it.
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
     *       parameters that will get banks across multiple contexts.
     * @return stdClass[]
     */
    public static function get_activity_instances_with_private_questions(
        array $incourseids = [],
        array $notincourseids = [],
        array $havingcap = [],
        bool $getcategories = false,
        int $currentbankid = 0,
        ?context $filtercontext = null,
    ): array {
        return self::get_bank_instances(false,
            $incourseids,
            $notincourseids,
            $getcategories,
            $currentbankid,
            $havingcap,
            $filtercontext,
        );
    }

    /**
     * Private method to build the SQL and get records from the DB. Called from public API methods
     * {@see self::get_activity_instances_with_shareable_questions()}
     * {@see self::get_activity_instances_with_private_questions()}
     *
     * @param bool $isshared true if you want instances that publish questions false if you want instances that don't
     * @param array $incourseids array of course ids where you want instances included. Leave empty if you want from all courses.
     * @param array $notincourseids array of course ids where you do not want instances included.
     * @param bool $getcategories optionally return the categories belonging to these banks.
     * @param int $currentbankid optionally include the bank id you want included as the first result from the method return.
     *  it will only be included if the other parameters allow it.
     * @param array $havingcap current user must have at least one of these capabilities on each bank context.
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
     *     parameters that will get banks across multiple contexts.
     * @param string $search Optional term to search question bank instances by name
     * @param int $limit The number of results to return (default 0 = no limit)
     * @return stdClass[]
     */
    private static function get_bank_instances(
        bool $isshared,
        array $incourseids = [],
        array $notincourseids = [],
        bool $getcategories = false,
        int $currentbankid = 0,
        array $havingcap = [],
        ?context $filtercontext = null,
        string $search = '',
        int $limit = 0,
    ): array {
        global $DB;

        $pluginssql = [];
        $params = [];

        // Build the SELECT portion of the SQL and include question category joins as required.
        if ($getcategories) {
            $concat = $DB->sql_concat('qc.id',
                "'" . self::CATEGORY_DELIMITER . "'",
                'qc.name',
                "'" . self::CATEGORY_DELIMITER . "'",
                'qc.contextid'
            );
            $groupconcat = $DB->sql_group_concat($concat, self::CATEGORY_SEPARATOR);
            $select = "SELECT cm.id, cm.course, {$groupconcat} AS cats";
            $catsql = ' JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = ' . CONTEXT_MODULE .
                ' JOIN {question_categories} qc ON qc.contextid = c.id AND qc.parent <> 0';
        } else {
            $select = 'SELECT cm.id, cm.course';
            $catsql = '';
        }

        if ($isshared) {
            $plugins = self::get_activity_types_with_shareable_questions();
        } else {
            $plugins = self::get_activity_types_with_private_questions();
        }

        if (empty($plugins)) {
            return [];
        }

        // Build the joins for all modules of the type requested i.e. those that do or do not share questions.
        foreach ($plugins as $key => $plugin) {
            $moduleid = $DB->get_field('modules', 'id', ['name' => $plugin]);
            $sql = "JOIN {{$plugin}} p{$key} ON p{$key}.id = cm.instance
                    AND cm.module = {$moduleid} AND cm.deletioninprogress = 0";
            if ($plugin === self::get_default_question_bank_activity_name()) {
                $sql .= " AND p{$key}.type <> '" . self::TYPE_PREVIEW . "'";
            }
            if (!empty($search)) {
                $sql .= " AND " . $DB->sql_like("p{$key}.name", ":search{$key}", false);
                $params["search{$key}"] = "%{$search}%";
            }
            $pluginssql[] = $sql;
        }
        $pluginssql = implode(' ', $pluginssql);

        // Build the SQL to filter out any requested course ids.
        if (!empty($notincourseids)) {
            [$notincoursesql, $notincourseparams] = $DB->get_in_or_equal($notincourseids, SQL_PARAMS_NAMED, 'param', false);
            $notincoursesql = "AND cm.course {$notincoursesql}";
            $params = array_merge($params, $notincourseparams);
        } else {
            $notincoursesql = '';
        }

        // Build the SQL to include ONLY records belonging to the requested courses.
        if (!empty($incourseids)) {
            [$incoursesql, $incourseparams] = $DB->get_in_or_equal($incourseids, SQL_PARAMS_NAMED);
            $incoursesql = " AND cm.course {$incoursesql}";
            $params = array_merge($params, $incourseparams);
        } else {
            $incoursesql = '';
        }

        // Optionally order the results by the requested bank id.
        if (!empty($currentbankid)) {
            $orderbysql = " ORDER BY CASE WHEN cm.id = :currentbankid THEN 0 ELSE 1 END ASC, cm.id DESC ";
            $params['currentbankid'] = $currentbankid;
        } else {
            $orderbysql = '';
        }

        $sql = "{$select}
                FROM {course_modules} cm
                JOIN {modules} m ON m.id = cm.module
                {$pluginssql}
                {$catsql}
                WHERE 1=1 {$notincoursesql} {$incoursesql}
                GROUP BY cm.id, cm.course
                {$orderbysql}";

        $rs = $DB->get_recordset_sql($sql, $params, limitnum: $limit);
        $banks = [];

        foreach ($rs as $cm) {
            // If capabilities have been supplied as a method argument then ensure the viewing user has at least one of those
            // capabilities on the module itself.
            if (!empty($havingcap)) {
                $context = \context_module::instance($cm->id);
                if (!(new question_edit_contexts($context))->have_one_cap($havingcap)) {
                    continue;
                }
            }
            // Populate the raw record.
            $banks[] = self::get_formatted_bank($cm, $currentbankid, filtercontext: $filtercontext);
        }
        $rs->close();

        return $banks;
    }

    /**
     * Get a list of recently viewed question banks that implement FEATURE_PUBLISHES_QUESTIONS.
     * If any of the stored contexts don't exist anymore then update the user preference record accordingly.
     *
     * @param int $userid of the user to get recently viewed banks for.
     * @param int $notincourseid if supplied don't return any in this course id
     * @param ?context $filtercontext Optional context to use for all string filtering, useful for performance when calling with
     *       parameters that will get banks across multiple contexts.
     * @return cm_info[]
     */
    public static function get_recently_used_open_banks(
        int $userid,
        int $notincourseid = 0,
        ?context $filtercontext = null,
        array $havingcap = [],
    ): array {
        $prefs = get_user_preferences(self::RECENTLY_VIEWED, null, $userid);
        $contextids = !empty($prefs) ? explode(',', $prefs) : [];
        if (empty($contextids)) {
            return $contextids;
        }
        $invalidcontexts = [];
        $banks = [];

        foreach ($contextids as $contextid) {
            if (!$context = context::instance_by_id($contextid, IGNORE_MISSING)) {
                $invalidcontexts[] = $context;
                continue;
            }
            if ($context->contextlevel !== CONTEXT_MODULE) {
                throw new \moodle_exception('Invalid question bank contextlevel: ' . $context->contextlevel);
            }
            [, $cm] = get_module_from_cmid($context->instanceid);
            if (!empty($notincourseid) && $notincourseid == $cm->course) {
                continue;
            }
            if (!empty($havingcap) && !(new question_edit_contexts($context))->have_one_cap($havingcap)) {
                continue;
            }
            $record = self::get_formatted_bank($cm, filtercontext: $filtercontext);
            $banks[] = $record;
        }

        if (!empty($invalidcontexts)) {
            $tostore = array_diff($contextids, $invalidcontexts);
            $tostore = implode(',', $tostore);
            set_user_preference(self::RECENTLY_VIEWED, $tostore, $userid);
        }

        return $banks;
    }

    /**
     * Mark a user as having viewed a question bank in the user_preferences table with key {@see self::RECENTLY_VIEWED}
     *
     * @param context $bankcontext add this bank context to the viewing user's list of recently viewed.
     * @return void
     */
    public static function add_bank_context_to_recently_viewed(context $bankcontext): void {

        [, $cm] = get_module_from_cmid($bankcontext->instanceid);

        if (!plugin_supports('mod', $cm->modname, FEATURE_PUBLISHES_QUESTIONS)) {
            return;
        }

        $userprefs = get_user_preferences(self::RECENTLY_VIEWED);
        $recentlyviewed = !empty($userprefs) ? explode(',', $userprefs) : [];
        $recentlyviewed = array_combine($recentlyviewed, $recentlyviewed);
        $tostore = [];
        $tostore[] = $bankcontext->id;
        if (!empty($recentlyviewed[$bankcontext->id])) {
            unset($recentlyviewed[$bankcontext->id]);
        }
        $tostore = array_merge($tostore, array_values($recentlyviewed));
        $tostore = array_slice($tostore, 0, 5);
        set_user_preference(self::RECENTLY_VIEWED, implode(',', $tostore));
    }

    /**
     * Populate the raw record with data for use in rendering.
     *
     * @param stdClass $cm raw course_modules record to populate data from.
     * @param int $currentbankid set an 'enabled' flag on the instance that matched this id.
     *     Used in qbank_bulkmove/bulk_move.mustache
     * @param ?context $filtercontext Optional context in which to apply filters.
     *
     * @return stdClass
     */
    private static function get_formatted_bank(stdClass $cm, int $currentbankid = 0, ?context $filtercontext = null): stdClass {

        $cminfo = cm_info::create($cm);
        $concatedcats = !empty($cm->cats) ? explode(self::CATEGORY_SEPARATOR, $cm->cats) : [];
        $categories = array_map(static function($concatedcategory) use ($cminfo, $currentbankid) {
            $values = explode(self::CATEGORY_DELIMITER, $concatedcategory);
            $cat = new stdClass();
            $cat->id = $values[0];
            $cat->name = $values[1];
            $cat->contextid = $values[2];
            $cat->enabled = $cminfo->id == $currentbankid ? 'enabled' : 'disabled';
            return $cat;
        }, $concatedcats);

        $bank = new stdClass();
        $filteroptions = ['escape' => false];
        if (!is_null($filtercontext)) {
            $filteroptions['context'] = $filtercontext;
        }
        $bank->name = $cminfo->get_formatted_name($filteroptions);
        $bank->modid = $cminfo->id;
        $bank->contextid = $cminfo->context->id;
        if (!isset($filteroptions['context'])) {
            $filteroptions['context'] = context_course::instance($cminfo->get_course()->id);
        }
        $bank->coursenamebankname = format_string($cminfo->get_course()->shortname, true, $filteroptions) . " - {$bank->name}";
        $bank->cminfo = $cminfo;
        $bank->questioncategories = $categories;
        return $bank;
    }

    /**
     * Get the system type qbank instance for this course, optionally create it if it does not yet exist.
     * {@see self::TYPE_SYSTEM}
     *
     * @param stdClass $course the course to get the default system type bank for.
     * @param bool $createifnotexists create a default bank if it does not exist.
     * @return cm_info|null
     */
    public static function get_default_open_instance_system_type(stdClass $course, bool $createifnotexists = false): ?cm_info {

        $modinfo = get_fast_modinfo($course);
        $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());
        $systembank = null;

        if ($systembankids = self::get_qbank_ids_of_type_in_course($course, self::TYPE_SYSTEM)) {
            // We should only ever have 1 of these.
            $systembankid = reset($systembankids);
            // Filter the course modinfo qbanks by the systembankid.
            $systembanks = array_filter($qbanks, static fn($bank) => $bank->id === $systembankid);
            $systembank = !empty($systembanks) ? reset($systembanks) : null;
        }

        if (!$systembank && $createifnotexists) {
            $systembank = self::create_default_open_instance(
                $course,
                self::get_bank_name_string('systembank', 'question'),
                self::TYPE_SYSTEM,
            );
        }

        return $systembank;
    }

    /**
     * Get the bank that is used for preview purposes only, optionally create it if it does not yet exist.
     * {@see \qbank_columnsortorder\column_manager::get_questionbank()}
     *
     * @param bool $createifnotexists create a default bank if it does not exist.
     * @return cm_info|null
     */
    public static function get_preview_open_instance_type(bool $createifnotexists = false): ?cm_info {

        $site = get_site();
        $modinfo = get_fast_modinfo($site);
        $qbanks = $modinfo->get_instances_of(self::get_default_question_bank_activity_name());
        $previewbank = null;

        if ($previewbankids = self::get_qbank_ids_of_type_in_course($site, self::TYPE_PREVIEW)) {
            // We should only ever have 1 of these.
            $previewbankid = reset($previewbankids);
            // Filter the course modinfo qbanks by the previewbankid.
            $previewbanks = array_filter($qbanks, static fn($bank) => $bank->id === $previewbankid);
            $previewbank = !empty($previewbanks) ? reset($previewbanks) : null;
        }

        if (!$previewbank && $createifnotexists) {
            $previewbank = self::create_default_open_instance(
                $site,
                self::get_bank_name_string('previewbank', 'question'),
                self::TYPE_PREVIEW
            );
        }

        return $previewbank;
    }

    /**
     * Get course module ids from qbank instances on a course that are of the sub-type provided.
     *
     * @param stdClass $course the course to search
     * @param string $subtype the subtype of the qbank module {@see self::SHARED_TYPES}
     * @return int[]
     */
    private static function get_qbank_ids_of_type_in_course(stdClass $course, string $subtype): array {
        global $DB;

        if (!in_array($subtype, self::SHARED_TYPES)) {
            throw new \moodle_exception('Invalid question bank type: ' . $subtype);
        }

        $modinfo = get_fast_modinfo($course);
        $defaultyactivityname = self::get_default_question_bank_activity_name();
        $qbanks = $modinfo->get_instances_of($defaultyactivityname);

        $whereclause = "AND m.name = '" . $defaultyactivityname . "'";

        if (!empty($qbanks)) {
            $sql = "SELECT cm.id
                      FROM {course_modules} cm
                      JOIN {modules} m ON m.id = cm.module
                      JOIN {{$defaultyactivityname}} q ON q.id = cm.instance
                     WHERE cm.course = :course
                       AND q.type = :type " .
            $whereclause;

            return $DB->get_fieldset_sql($sql, ['type' => $subtype, 'course' => $course->id]);
        }

        return [];
    }

    /**
     * Create a bank on the course from default options.
     *
     * @param stdClass $course the course that the new module is being created in
     * @param string $bankname name of the new module
     * @param string $type {@see self::TYPES}
     * @return cm_info
     */
    public static function create_default_open_instance(
        stdClass $course,
        string $bankname,
        string $type = self::TYPE_STANDARD
    ): cm_info {
        global $DB;

        if (!in_array($type, self::SHARED_TYPES)) {
            throw new \RuntimeException('invalid type');
        }

        // Preview bank must be created at site course.
        if ($type === self::TYPE_PREVIEW) {
            if ($qbank = self::get_preview_open_instance_type()) {
                return $qbank;
            }
            $course = get_site();
        }

        // We can only have one of these types per course.
        if ($type === self::TYPE_SYSTEM && $qbank = self::get_default_open_instance_system_type($course)) {
            return $qbank;
        }

        $module = $DB->get_record('modules', ['name' => self::get_default_question_bank_activity_name()], '*', MUST_EXIST);
        $context = context_course::instance($course->id);

        // STANDARD type needs capability checks.
        if ($type === self::TYPE_STANDARD) {
            require_capability('moodle/course:manageactivities', $context);
            if (!course_allowed_module($course, $module->name)) {
                throw new \moodle_exception('moduledisable');
            }
        }

        if (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH) {
            throw new \coding_exception(
                'The provided bankname is too long for the database field.',
                'Use question_bank_helper::get_bank_name_string to get a suitably truncated name.',
            );
        }

        $data = new stdClass();
        $data->section = 0;
        $data->visible = 0;
        $data->course = $course->id;
        $data->module = $module->id;
        $data->modulename = $module->name;
        $data->groupmode = $course->groupmode;
        $data->groupingid = $course->defaultgroupingid;
        $data->id = '';
        $data->instance = '';
        $data->coursemodule = '';
        $data->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED;
        $data->visibleoncoursepage = 0;
        $data->name = $bankname;
        $data->type = in_array($type, self::SHARED_TYPES) ? $type : self::TYPE_STANDARD;
        $data->showdescription = $type === self::TYPE_STANDARD ? 0 : 1;
        // Don't create the default category if this is being created by the system as part of a migration or restore,
        // existing categories will be migrated to the new context.
        $data->skipdefaultcategory = $type === self::TYPE_SYSTEM;

        $mod = add_moduleinfo($data, $course);

        // Have to set this manually as the system because this bank type is not intended to be created directly by a user.
        if ($type === self::TYPE_SYSTEM) {
            $DB->set_field($module->name, 'intro', get_string('systembankdescription', 'question'), ['id' => $mod->instance]);
            $DB->set_field($module->name, 'introformat', FORMAT_HTML, ['id' => $mod->instance]);
        }

        return get_fast_modinfo($course)->get_cm($mod->coursemodule);
    }

    /**
     * Get the url that shows the banks list of a course.
     *
     * @param int $courseid of the course to get the url for.
     * @param bool $createdefault Pass true if you want the URL to create a default qbank instance when referred.
     * @return moodle_url
     */
    public static function get_url_for_qbank_list(int $courseid, bool $createdefault = false): moodle_url {
        $url = new moodle_url('/question/banks.php', ['courseid' => $courseid]);
        if ($createdefault) {
            $url->param('createdefault', true);
        }
        return $url;
    }

    /**
     * This task should only ever be called once, on install/upgrade. But we may need to warn the user on some pages
     * that some banks may not have been transferred yet if it failed or hasn't yet completed.
     *
     * @return bool
     */
    public static function has_bank_migration_task_completed_successfully(): bool {
        $defaultbank = self::get_default_question_bank_activity_name();
        $task = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_question_categories");
        $subtasks = manager::get_adhoc_tasks("\\mod_{$defaultbank}\\task\\transfer_questions");
        return empty($task) && empty($subtasks);
    }

    /**
     * Get the activity plugin name that will be the type used for default bank creation and management.
     *
     * @return string
     */
    public static function get_default_question_bank_activity_name(): string {
        global $CFG;
        return $CFG->corequestion_defaultqbankmod ?? 'qbank';
    }

    /**
     * Get the requested language string, with parameters truncated to ensure the result fits in the database.
     *
     * Since we may be generating a question bank name based on an existing course or category name, we need to ensure
     * that the resulting string isn't longer than the maximum module name.
     *
     * @param string $identifier The string identifier
     * @param string $component The string component
     * @param mixed|null $params The string parameters (a single string, array or object as accepted by get_string)
     * @return string The string truncated to a length that will fit in the database.
     */
    public static function get_bank_name_string(string $identifier, string $component, mixed $params = null): string {
        if (is_object($params)) {
            $shortparams = (array) $params;
        } else {
            $shortparams = $params;
        }
        $bankname = get_string($identifier, $component, $shortparams);
        if (!is_null($shortparams)) {
            $trimlength = self::BANK_NAME_MAX_LENGTH - 4;
            while (\core_text::strlen($bankname) > self::BANK_NAME_MAX_LENGTH && $trimlength > 0) {
                // Gradually shorten the string parameters until the resulting string is short enough.
                if (is_array($shortparams)) {
                    $shortparams = array_map(fn($param) => shorten_text(trim($param), $trimlength), $shortparams);
                } else {
                    $shortparams = shorten_text(trim($shortparams), $trimlength);
                }
                $bankname = get_string($identifier, $component, $shortparams);
                $trimlength -= 10;
            }
        }
        // As a failsafe, limit the length of the final string in case the lang string is too long.
        return shorten_text($bankname, self::BANK_NAME_MAX_LENGTH);
    }
}