diff --git a/CHANGELOG.md b/CHANGELOG.md index 316dfdf..70f7901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. - When a section used to be displayed on a course page and is now displayed as a link, the old student preferences about collapsed state affect the visibility of the section summary #68 +- Coding error when trying to collapse a large number of sections #88 + (workaround for the core bug MDL-78073) ## [4.1.1] - 2024-10-02 ### Fixed diff --git a/classes/local/helpers/preferences.php b/classes/local/helpers/preferences.php new file mode 100644 index 0000000..b420f02 --- /dev/null +++ b/classes/local/helpers/preferences.php @@ -0,0 +1,162 @@ +. + +namespace format_flexsections\local\helpers; + +use cache; +use core_text; + +/** + * Helps to store and retrieve large user preferences + * + * @package format_flexsections + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait preferences { + + /** + * Name for the part of the preference + * + * @param string $name + * @param int $cnt + * @return string + */ + protected static function get_preference_name(string $name, int $cnt = 0): string { + return $cnt ? "{$name}#{$cnt}" : $name; + } + + /** + * Set a preference for the user (value can be longer than 1333) + * + * @param string $name + * @param string|null $value + * @return void + */ + public static function set_long_preference(string $name, ?string $value): void { + $allpreferences = array_filter(get_user_preferences(), function($prefname) use ($name) { + return $prefname === $name || (strpos($prefname, "{$name}#") === 0); + }, ARRAY_FILTER_USE_KEY); + $len = ceil(core_text::strlen((string)$value) / 1300); + for ($cnt = 0; $cnt < $len; $cnt++) { + $pref = self::get_preference_name($name, $cnt); + set_user_preference($pref, core_text::substr($value, $cnt * 1300, 1300)); + unset($allpreferences[$pref]); + } + foreach (array_keys($allpreferences) as $pref) { + unset_user_preference($pref); + } + } + + /** + * Get a preference for the user (value can be longer than 1333) + * + * @param string $name + * @return string + */ + public static function get_long_preference(string $name): string { + $allpreferences = array_filter(get_user_preferences(), function($prefname) use ($name) { + return $prefname === $name || (strpos($prefname, "{$name}#") === 0); + }, ARRAY_FILTER_USE_KEY); + $value = ''; + for ($cnt = 0; $cnt < count($allpreferences); $cnt++) { + $value .= $allpreferences[self::get_preference_name($name, $cnt)] ?? ''; + } + return $value; + } + + /** + * Return the format section preferences. + * + * @return array of preferences indexed by preference name + */ + public function get_sections_preferences_by_preference(): array { + $course = $this->get_course(); + try { + $sectionpreferences = json_decode( + self::get_long_preference("coursesectionspreferences_{$course->id}"), + true, + ) ?: []; + } catch (\Throwable $e) { + $sectionpreferences = []; + } + return $sectionpreferences; + } + + /** + * Return the format section preferences. + * + * @param string $preferencename preference name + * @param int[] $sectionids affected section ids + * + */ + public function set_sections_preference(string $preferencename, array $sectionids) { + $sectionpreferences = $this->get_sections_preferences_by_preference(); + $sectionpreferences[$preferencename] = $sectionids; + $this->persist_to_user_preference($sectionpreferences); + } + + /** + * Add section preference ids. + * + * @param string $preferencename preference name + * @param array $sectionids affected section ids + */ + public function add_section_preference_ids(string $preferencename, array $sectionids): void { + $sectionpreferences = $this->get_sections_preferences_by_preference(); + if (!isset($sectionpreferences[$preferencename])) { + $sectionpreferences[$preferencename] = []; + } + foreach ($sectionids as $sectionid) { + if (!in_array($sectionid, $sectionpreferences[$preferencename])) { + $sectionpreferences[$preferencename][] = $sectionid; + } + } + $this->persist_to_user_preference($sectionpreferences); + } + + /** + * Remove section preference ids. + * + * @param string $preferencename preference name + * @param array $sectionids affected section ids + */ + public function remove_section_preference_ids(string $preferencename, array $sectionids): void { + $sectionpreferences = $this->get_sections_preferences_by_preference(); + if (!isset($sectionpreferences[$preferencename])) { + $sectionpreferences[$preferencename] = []; + } + foreach ($sectionids as $sectionid) { + if (($key = array_search($sectionid, $sectionpreferences[$preferencename])) !== false) { + unset($sectionpreferences[$preferencename][$key]); + } + } + $this->persist_to_user_preference($sectionpreferences); + } + + /** + * Persist the section preferences to the user preferences. + * + * @param array $sectionpreferences the section preferences + */ + private function persist_to_user_preference(array $sectionpreferences): void { + $course = $this->get_course(); + self::set_long_preference('coursesectionspreferences_' . $course->id, json_encode($sectionpreferences)); + // Invalidate section preferences cache. + $coursesectionscache = cache::make('core', 'coursesectionspreferences'); + $coursesectionscache->delete($course->id); + } +} diff --git a/lib.php b/lib.php index b1e2dbc..56f7ff6 100644 --- a/lib.php +++ b/lib.php @@ -27,6 +27,7 @@ use format_flexsections\constants; use core\output\inplace_editable; +use format_flexsections\local\helpers\preferences; define('FORMAT_FLEXSECTIONS_COLLAPSED', 1); define('FORMAT_FLEXSECTIONS_EXPANDED', 0); @@ -39,6 +40,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class format_flexsections extends core_courseformat\base { + use preferences; /** * Returns true if this course format uses sections. diff --git a/tests/local/helpers/preferences_test.php b/tests/local/helpers/preferences_test.php new file mode 100644 index 0000000..2604170 --- /dev/null +++ b/tests/local/helpers/preferences_test.php @@ -0,0 +1,189 @@ +. + +namespace format_flexsections\local\helpers; +use format_flexsections; + +/** + * Tests for Flexible sections format + * + * @covers \format_flexsections\local\helpers\preferences + * @package format_flexsections + * @category test + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class preferences_test extends \advanced_testcase { + + public function setUp(): void { + global $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + parent::setUp(); + } + + public function test_long_preferences(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + format_flexsections::set_long_preference('name', 'value'); + $this->assertEquals('value', format_flexsections::get_long_preference('name')); + $preferences = get_user_preferences(); + $this->assertArrayHasKey('name', $preferences); + $this->assertArrayNotHasKey('name#1', $preferences); + + $longpref = random_string(2000); + format_flexsections::set_long_preference('name', $longpref); + $this->assertEquals($longpref, format_flexsections::get_long_preference('name')); + $preferences = get_user_preferences(); + $this->assertArrayHasKey('name', $preferences); + $this->assertArrayHasKey('name#1', $preferences); + $this->assertArrayNotHasKey('name#2', $preferences); + + $verylongpref = random_string(1300 * 5 + 500); + format_flexsections::set_long_preference('name', $verylongpref); + $this->assertEquals($verylongpref, format_flexsections::get_long_preference('name')); + $preferences = get_user_preferences(); + $this->assertArrayHasKey('name', $preferences); + $this->assertArrayHasKey('name#1', $preferences); + $this->assertArrayHasKey('name#2', $preferences); + $this->assertArrayHasKey('name#3', $preferences); + $this->assertArrayHasKey('name#4', $preferences); + $this->assertArrayHasKey('name#5', $preferences); + $this->assertArrayNotHasKey('name#6', $preferences); + + format_flexsections::set_long_preference('name', 'value again'); + $this->assertEquals('value again', format_flexsections::get_long_preference('name')); + $preferences = get_user_preferences(); + $this->assertArrayHasKey('name', $preferences); + $this->assertArrayNotHasKey('name#1', $preferences); + + format_flexsections::set_long_preference('name', null); + $this->assertEquals('', format_flexsections::get_long_preference('name')); + $preferences = get_user_preferences(); + $this->assertArrayNotHasKey('name', $preferences); + } + + /** + * Test for the default delete format data behaviour. + * + * @covers ::set_sections_preference + */ + public function test_set_sections_preference(): void { + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['numsections' => 200, 'format' => 'flexsections'], + ['createsections' => true]); + $user = $generator->create_and_enrol($course, 'student'); + + $format = course_get_format($course); + $this->setUser($user); + + // Load data from user 1. + $format->set_sections_preference('pref1', [1, 2]); + $format->set_sections_preference('pref2', [1]); + $format->set_sections_preference('pref3', []); + + $preferences = $format->get_sections_preferences(); + $this->assertEquals( + (object)['pref1' => true, 'pref2' => true], + $preferences[1] + ); + $this->assertEquals( + (object)['pref1' => true], + $preferences[2] + ); + } + + public function test_add_section_preference_ids(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['numsections' => 200, 'format' => 'flexsections'], + ['createsections' => true]); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setUser($user); + + $format = course_get_format($course); + + // Add section preference ids. + $format->add_section_preference_ids('pref1', [1, 2]); + $format->add_section_preference_ids('pref1', [3]); + $format->add_section_preference_ids('pref2', [1]); + + // Get section preferences. + $sectionpreferences = $format->get_sections_preferences_by_preference(); + $this->assertCount(3, $sectionpreferences['pref1']); + $this->assertContains(1, $sectionpreferences['pref1']); + $this->assertContains(2, $sectionpreferences['pref1']); + $this->assertContains(3, $sectionpreferences['pref1']); + $this->assertCount(1, $sectionpreferences['pref2']); + $this->assertContains(1, $sectionpreferences['pref1']); + } + + public function test_add_section_preference_ids_long(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['numsections' => 500, 'format' => 'flexsections'], + ['createsections' => true]); + $user = $this->getDataGenerator()->create_and_enrol($course); + $this->setUser($user); + + $format = course_get_format($course); + + // Add section preference ids. + $vals = array_keys(array_fill(0, 500, true)); + $format->add_section_preference_ids('contentcollapsed', $vals); + + // Get section preferences. + $sectionpreferences = $format->get_sections_preferences_by_preference(); + $this->assertCount(500, $sectionpreferences['contentcollapsed']); + } + + /** + * Test remove_section_preference_ids() method. + * + * @covers \core_courseformat\base::persist_to_user_preference + */ + public function test_remove_section_preference_ids(): void { + $this->resetAfterTest(); + // Create initial data. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['numsections' => 200, 'format' => 'flexsections'], + ['createsections' => true]); + $user = $generator->create_and_enrol($course); + // Get the course format. + $format = course_get_format($course); + // Login as the user. + $this->setUser($user); + // Set initial preferences. + $format->set_sections_preference('pref1', [1, 2, 3]); + $format->set_sections_preference('pref2', [1]); + + // Remove section with id = 3 out of the pref1. + $format->remove_section_preference_ids('pref1', [3]); + // Get section preferences. + $sectionpreferences = $format->get_sections_preferences_by_preference(); + $this->assertCount(2, $sectionpreferences['pref1']); + $this->assertCount(1, $sectionpreferences['pref2']); + + // Remove section with id = 2 out of the pref1. + $format->remove_section_preference_ids('pref1', [2]); + // Remove section with id = 1 out of the pref2. + $format->remove_section_preference_ids('pref2', [1]); + // Get section preferences. + $sectionpreferences = $format->get_sections_preferences_by_preference(); + $this->assertCount(1, $sectionpreferences['pref1']); + $this->assertEmpty($sectionpreferences['pref2']); + } +}