├── .gitattributes ├── styles.css ├── README.md ├── pix └── monologo.svg ├── db ├── install.xml ├── services.php └── access.php ├── version.php ├── classes ├── privacy │ └── provider.php ├── event │ ├── course_module_instance_list_viewed.php │ ├── course_module_viewed.php │ └── pattern_updated.php ├── external │ └── paint.php └── api.php ├── lang └── en │ └── rplace.php ├── tests ├── generator │ └── lib.php ├── behat │ └── basic_actions.feature └── lib_test.php ├── backup └── moodle2 │ ├── backup_rplace_stepslib.php │ ├── restore_rplace_stepslib.php │ ├── backup_rplace_activity_task.class.php │ └── restore_rplace_activity_task.class.php ├── mod_form.php ├── index.php ├── view.php ├── amd ├── build │ ├── rplace.min.js │ └── rplace.min.js.map └── src │ └── rplace.js ├── lib.php └── .github └── workflows └── gha.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | **/yui/build/** -diff 2 | **/amd/build/** -diff 3 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .mod_rplace_pattern, 2 | .mod_rplace_chooser { 3 | border: 1px solid black; 4 | } 5 | 6 | .mod_rplace_pattern td, 7 | .mod_rplace_chooser td { 8 | width: 2em; 9 | text-align: center; 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Module RPLACE 2 | ============= 3 | 4 | Plugin for demonstrating [tool_realtime](https://github.com/marinaglancy/moodle-tool_realtime) 5 | 6 | - Add an instance of mod_rplace to a course 7 | - Simultaneously open the module page as several users (in different browsers, for example) 8 | - Paint pixes as one user and make sure that all other users receive updates 9 | -------------------------------------------------------------------------------- /pix/monologo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /db/install.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /version.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Version information for Rplace 19 | * 20 | * @package mod_rplace 21 | * @copyright 2024 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | $plugin->component = 'mod_rplace'; 28 | $plugin->release = '1.3'; 29 | $plugin->version = 2024080600; 30 | $plugin->requires = 2022112800; 31 | $plugin->supported = [401, 405]; 32 | $plugin->maturity = MATURITY_STABLE; 33 | $plugin->dependencies = ['tool_realtime' => 2024080100]; 34 | -------------------------------------------------------------------------------- /db/services.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * External functions and service declaration for Rplace 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/apis/subsystems/external/description} 21 | * 22 | * @package mod_rplace 23 | * @category webservice 24 | * @copyright 2024 Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | 28 | defined('MOODLE_INTERNAL') || die(); 29 | 30 | $functions = [ 31 | 32 | 'mod_rplace_paint' => [ 33 | 'classname' => mod_rplace\external\paint::class, 34 | 'description' => 'Paint a dot', 35 | 'type' => 'write', 36 | 'ajax' => true, 37 | ], 38 | ]; 39 | 40 | $services = [ 41 | ]; 42 | -------------------------------------------------------------------------------- /classes/privacy/provider.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace\privacy; 18 | 19 | use core_privacy\local\metadata\null_provider; 20 | 21 | /** 22 | * Privacy Subsystem for mod_rplace implementing null_provider. 23 | * 24 | * @package mod_rplace 25 | * @copyright 2024 Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class provider implements null_provider { 29 | 30 | /** 31 | * Get the language string identifier with the component's language 32 | * file to explain why this plugin stores no data. 33 | * 34 | * @return string 35 | */ 36 | public static function get_reason(): string { 37 | return 'privacy:metadata'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /classes/event/course_module_instance_list_viewed.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace\event; 18 | 19 | /** 20 | * Event course_module_instance_list_viewed 21 | * 22 | * @package mod_rplace 23 | * @copyright 2024 Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed { 27 | 28 | /** 29 | * Create the event from course record. 30 | * 31 | * @param \stdClass $course 32 | * @return course_module_instance_list_viewed 33 | */ 34 | public static function create_from_course(\stdClass $course) { 35 | $params = [ 36 | 'context' => \context_course::instance($course->id), 37 | ]; 38 | /** @var course_module_instance_list_viewed $event */ 39 | $event = static::create($params); 40 | $event->add_record_snapshot('course', $course); 41 | return $event; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lang/en/rplace.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * English language pack for Rplace 19 | * 20 | * @package mod_rplace 21 | * @category string 22 | * @copyright 2024 Marina Glancy 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $string['clearall'] = 'Clear all'; 29 | $string['clicktodraw'] = 'Click anywhere to draw'; 30 | $string['instantfeedback'] = 'Show instant feedback on own actions'; 31 | $string['modulename'] = 'Rplace'; 32 | $string['modulenameplural'] = 'Rplaces'; 33 | $string['patternupdated'] = 'Pattern updated'; 34 | $string['pickacolor'] = 'Pick a color'; 35 | $string['pluginadministration'] = 'Rplace administration'; 36 | $string['pluginname'] = 'Rplace'; 37 | $string['privacy:metadata'] = 'The Rplace plugin doesn\'t store any personal data.'; 38 | $string['rplace:addinstance'] = 'Add a new Rplace'; 39 | $string['rplace:clearall'] = 'Clear all'; 40 | $string['rplace:paint'] = 'Paint in mod_rplace'; 41 | $string['rplace:view'] = 'View Rplace'; 42 | -------------------------------------------------------------------------------- /tests/generator/lib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Data generator class 19 | * 20 | * @package mod_rplace 21 | * @category test 22 | * @copyright 2024 Marina Glancy 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | class mod_rplace_generator extends testing_module_generator { 26 | 27 | /** 28 | * Creates an instance of the module for testing purposes. 29 | * 30 | * Module type will be taken from the class name. 31 | * 32 | * @param array|stdClass $record data for module being generated. Requires 'course' key 33 | * (an id or the full object). Also can have any fields from add module form. 34 | * @param null|array $options general options for course module, can be merged into $record 35 | * @return stdClass record from module-defined table with additional field 36 | * cmid (corresponding id in course_modules table) 37 | */ 38 | public function create_instance($record = null, ?array $options = null) { 39 | $record = (object)(array)$record; 40 | // TODO add default values for plugin-specific fields here. 41 | $instance = parent::create_instance($record, (array)$options); 42 | 43 | return $instance; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /classes/event/course_module_viewed.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace\event; 18 | 19 | /** 20 | * Event course_module_viewed 21 | * 22 | * @package mod_rplace 23 | * @copyright 2024 Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class course_module_viewed extends \core\event\course_module_viewed { 27 | 28 | /** 29 | * Init method. 30 | */ 31 | protected function init() { 32 | parent::init(); 33 | $this->data['objecttable'] = 'rplace'; 34 | } 35 | 36 | /** 37 | * Creates an instance of event 38 | * 39 | * @param \stdClass $record 40 | * @param \cm_info|\stdClass $cm 41 | * @param \stdClass $course 42 | * @return course_module_viewed 43 | */ 44 | public static function create_from_record($record, $cm, $course) { 45 | /** @var course_module_viewed $event */ 46 | $event = self::create([ 47 | 'objectid' => $record->id, 48 | 'context' => \context_module::instance($cm->id), 49 | ]); 50 | $event->add_record_snapshot('course_modules', $cm); 51 | $event->add_record_snapshot('course', $course); 52 | $event->add_record_snapshot('rplace', $record); 53 | return $event; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backup/moodle2/backup_rplace_stepslib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Provides all the settings and steps to perform one complete backup of the activity 19 | * 20 | * @package mod_rplace 21 | * @copyright 2024 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | class backup_rplace_activity_structure_step extends backup_activity_structure_step { 25 | 26 | /** 27 | * Backup structure 28 | */ 29 | protected function define_structure() { 30 | 31 | // To know if we are including userinfo. 32 | $userinfo = $this->get_setting_value('userinfo'); 33 | 34 | // TODO: add all additional fields from the rplace table. 35 | $rplace = new backup_nested_element('rplace', ['id'], 36 | ['name', 'intro', 'introformat', 'timemodified']); 37 | 38 | // Define sources. 39 | $rplace->set_source_table('rplace', ['id' => backup::VAR_ACTIVITYID]); 40 | 41 | // Define id annotations. 42 | // TODO: add all additional id annotations. 43 | 44 | // Define file annotations. 45 | // TODO: add all additional file annotations. 46 | $rplace->annotate_files('mod_rplace', 'intro', null); 47 | 48 | // Return the root element (rplace), wrapped into standard activity structure. 49 | return $this->prepare_activity_structure($rplace); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /mod_form.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/course/moodleform_mod.php'); 20 | 21 | /** 22 | * Form for adding and editing Rplace instances 23 | * 24 | * @package mod_rplace 25 | * @copyright 2024 Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class mod_rplace_mod_form extends moodleform_mod { 29 | 30 | /** 31 | * Defines forms elements 32 | */ 33 | public function definition() { 34 | global $CFG; 35 | 36 | $mform = $this->_form; 37 | 38 | // General fieldset. 39 | $mform->addElement('header', 'general', get_string('general', 'form')); 40 | 41 | $mform->addElement('text', 'name', get_string('name'), ['size' => '64']); 42 | $mform->setType('name', empty($CFG->formatstringstriptags) ? PARAM_CLEANHTML : PARAM_TEXT); 43 | $mform->addRule('name', null, 'required', null, 'client'); 44 | $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); 45 | 46 | if (!empty($this->_features->introeditor)) { 47 | // Description element that is usually added to the General fieldset. 48 | $this->standard_intro_elements(); 49 | } 50 | 51 | // Other standard elements that are displayed in their own fieldsets. 52 | $this->standard_grading_coursemodule_elements(); 53 | $this->standard_coursemodule_elements(); 54 | 55 | $this->add_action_buttons(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /backup/moodle2/restore_rplace_stepslib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Structure step to restore one Rplace activity 19 | * 20 | * @package mod_rplace 21 | * @copyright 2024 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | class restore_rplace_activity_structure_step extends restore_activity_structure_step { 25 | 26 | /** 27 | * Structure step to restore one rplace activity 28 | * 29 | * @return array 30 | */ 31 | protected function define_structure() { 32 | 33 | $paths = []; 34 | $paths[] = new restore_path_element('rplace', '/activity/rplace'); 35 | 36 | // Return the paths wrapped into standard activity structure. 37 | return $this->prepare_activity_structure($paths); 38 | } 39 | 40 | /** 41 | * Process a rplace restore 42 | * 43 | * @param array $data 44 | * @return void 45 | */ 46 | protected function process_rplace($data) { 47 | global $DB; 48 | 49 | $data = (object)$data; 50 | $oldid = $data->id; 51 | $data->course = $this->get_courseid(); 52 | 53 | // Insert the rplace record. 54 | $newitemid = $DB->insert_record('rplace', $data); 55 | // Immediately after inserting "activity" record, call this. 56 | $this->apply_activity_instance($newitemid); 57 | } 58 | 59 | /** 60 | * Actions to be executed after the restore is completed 61 | */ 62 | protected function after_execute() { 63 | // Add rplace related files, no need to match by itemname (just internally handled context). 64 | $this->add_related_files('mod_rplace', 'intro', null); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /backup/moodle2/backup_rplace_activity_task.class.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/mod/rplace/backup/moodle2/backup_rplace_stepslib.php'); 20 | 21 | /** 22 | * Provides the steps to perform one complete backup of the Rplace instance 23 | * 24 | * @package mod_rplace 25 | * @copyright 2024 Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class backup_rplace_activity_task extends backup_activity_task { 29 | 30 | /** 31 | * No specific settings for this activity 32 | */ 33 | protected function define_my_settings() { 34 | } 35 | 36 | /** 37 | * Defines a backup step to store the instance data in the rplace.xml file 38 | */ 39 | protected function define_my_steps() { 40 | $this->add_step(new backup_rplace_activity_structure_step('rplace_structure', 'rplace.xml')); 41 | } 42 | 43 | /** 44 | * Encodes URLs to the index.php and view.php scripts 45 | * 46 | * @param string $content some HTML text that eventually contains URLs to the activity instance scripts 47 | * @return string the content with the URLs encoded 48 | */ 49 | public static function encode_content_links($content) { 50 | global $CFG; 51 | 52 | $base = preg_quote($CFG->wwwroot, "/"); 53 | 54 | // Link to the list of rplaces. 55 | $search = "/(".$base."\/mod\/rplace\/index.php\?id\=)([0-9]+)/"; 56 | $content = preg_replace($search, '$@RPLACEINDEX*$2@$', $content); 57 | 58 | // Link to rplace view by moduleid. 59 | $search = "/(".$base."\/mod\/rplace\/view.php\?id\=)([0-9]+)/"; 60 | $content = preg_replace($search, '$@RPLACEVIEWBYID*$2@$', $content); 61 | 62 | return $content; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/behat/basic_actions.feature: -------------------------------------------------------------------------------- 1 | @mod @mod_rplace 2 | Feature: Basic operations with module Rplace 3 | In order to use Rplace in Moodle 4 | As a teacher and student 5 | I need to be able to modify and view Rplace 6 | 7 | Background: 8 | Given the following "users" exist: 9 | | username | firstname | lastname | email | 10 | | student1 | Sam | Student | student1@example.com | 11 | | teacher1 | Terry | Teacher | teacher1@example.com | 12 | And the following "courses" exist: 13 | | fullname | shortname | category | 14 | | Course 1 | C1 | 0 | 15 | And the following "course enrolments" exist: 16 | | user | course | role | 17 | | student1 | C1 | student | 18 | | teacher1 | C1 | editingteacher | 19 | 20 | @javascript 21 | Scenario: Viewing Rplace module and activities index page 22 | Given the following "activities" exist: 23 | | activity | name | course | intro | section | 24 | | rplace | Test module name | C1 | Test module description | 1 | 25 | When I log in as "teacher1" 26 | And I am on "Course 1" course homepage with editing mode on 27 | And I add the "Activities" block 28 | And I log out 29 | And I log in as "student1" 30 | And I am on "Course 1" course homepage 31 | And I click on "Test module name" "link" in the "region-main" "region" 32 | And I should see "Test module description" 33 | And I am on "Course 1" course homepage 34 | And I click on "Rplaces" "link" in the "Activities" "block" 35 | And I should see "1" in the "Test module name" "table_row" 36 | 37 | @javascript 38 | Scenario: Creating and updating Rplace module 39 | When I log in as "teacher1" 40 | And I am on "Course 1" course homepage with editing mode on 41 | And I add a "Rplace" to section 1 using the activity chooser 42 | And I set the following fields to these values: 43 | | Name | Test module name | 44 | | Description | Test module description | 45 | | Display description on course page | 1 | 46 | And I press "Save and return to course" 47 | And I open "Test module name" actions menu 48 | And I click on "Edit settings" "link" in the "Test module name" activity 49 | And I set the field "Name" to "Test module new name" 50 | And I press "Save and return to course" 51 | And I should see "Test module new name" 52 | And I should not see "Test module name" 53 | And I should see "Test module description" 54 | -------------------------------------------------------------------------------- /db/access.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Capability definitions for Rplace 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/apis/subsystems/access} 21 | * 22 | * @package mod_rplace 23 | * @category access 24 | * @copyright 2024 Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | 28 | defined('MOODLE_INTERNAL') || die(); 29 | 30 | $capabilities = [ 31 | 32 | 'mod/rplace:view' => [ 33 | 'captype' => 'read', 34 | 'contextlevel' => CONTEXT_COURSE, 35 | 'archetypes' => [ 36 | 'guest' => CAP_ALLOW, 37 | 'student' => CAP_ALLOW, 38 | 'teacher' => CAP_ALLOW, 39 | 'editingteacher' => CAP_ALLOW, 40 | 'manager' => CAP_ALLOW, 41 | ], 42 | ], 43 | 44 | 'mod/rplace:addinstance' => [ 45 | 'captype' => 'write', 46 | 'riskbitmask' => RISK_XSS, 47 | 'contextlevel' => CONTEXT_COURSE, 48 | 'archetypes' => [ 49 | 'editingteacher' => CAP_ALLOW, 50 | 'manager' => CAP_ALLOW, 51 | ], 52 | 'clonepermissionsfrom' => 'moodle/course:manageactivities', 53 | ], 54 | 55 | 'mod/rplace:paint' => [ 56 | 'captype' => 'write', 57 | 'contextlevel' => CONTEXT_MODULE, 58 | 'archetypes' => [ 59 | 'student' => CAP_ALLOW, 60 | 'editingteacher' => CAP_ALLOW, 61 | 'teacher' => CAP_ALLOW, 62 | 'manager' => CAP_ALLOW, 63 | ], 64 | ], 65 | 66 | 'mod/rplace:clearall' => [ 67 | 'captype' => 'write', 68 | 'riskbitmask' => RISK_DATALOSS, 69 | 'contextlevel' => CONTEXT_MODULE, 70 | 'archetypes' => [ 71 | 'teacher' => CAP_ALLOW, 72 | 'editingteacher' => CAP_ALLOW, 73 | 'coursecreator' => CAP_ALLOW, 74 | 'manager' => CAP_ALLOW, 75 | ], 76 | ], 77 | ]; 78 | -------------------------------------------------------------------------------- /classes/event/pattern_updated.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace\event; 18 | 19 | /** 20 | * Event pattern_updated 21 | * 22 | * @package mod_rplace 23 | * @copyright 2024 Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class pattern_updated extends \core\event\base { 27 | 28 | /** 29 | * Set basic properties for the event. 30 | */ 31 | protected function init() { 32 | $this->data['objecttable'] = 'rplace'; 33 | $this->data['crud'] = 'u'; 34 | $this->data['edulevel'] = self::LEVEL_PARTICIPATING; 35 | } 36 | 37 | /** 38 | * Returns description of what happened. 39 | * 40 | * @return string 41 | */ 42 | public function get_description() { 43 | $x = $this->other['x']; 44 | $y = $this->other['y']; 45 | $color = $this->other['color']; 46 | return "The user with id '$this->userid' painted the ($x, $y) point with color $color " . 47 | "in 'rplace' activity with course module id '$this->contextinstanceid'."; 48 | } 49 | 50 | /** 51 | * Return localised event name. 52 | * 53 | * @return string 54 | */ 55 | public static function get_name() { 56 | return get_string('patternupdated', 'mod_rplace'); 57 | } 58 | 59 | /** 60 | * Creates an instance of event 61 | * 62 | * @param \cm_info|\stdClass $cm 63 | * @param int $x 64 | * @param int $y 65 | * @param int $color 66 | * @return pattern_updated 67 | */ 68 | public static function create_from_coordinates($cm, int $x, int $y, int $color) { 69 | /** @var pattern_updated $event */ 70 | $event = self::create([ 71 | 'objectid' => $cm->instance, 72 | 'context' => \context_module::instance($cm->id), 73 | 'other' => ['x' => $x, 'y' => $y, 'color' => $color], 74 | ]); 75 | $event->add_record_snapshot('course_modules', $cm); 76 | return $event; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Display information about all the Rplace modules in the requested course 19 | * 20 | * @package mod_rplace 21 | * @copyright 2024 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | require(__DIR__ . '/../../config.php'); 26 | 27 | $id = required_param('id', PARAM_INT); 28 | 29 | $course = $DB->get_record('course', ['id' => $id], '*', MUST_EXIST); 30 | require_course_login($course); 31 | 32 | \mod_rplace\event\course_module_instance_list_viewed::create_from_course($course)->trigger(); 33 | 34 | $PAGE->set_url('/mod/rplace/index.php', ['id' => $id]); 35 | $PAGE->set_title(format_string($course->fullname)); 36 | $PAGE->set_heading(format_string($course->fullname)); 37 | 38 | echo $OUTPUT->header(); 39 | 40 | $modulenameplural = get_string('modulenameplural', 'mod_rplace'); 41 | echo $OUTPUT->heading($modulenameplural); 42 | 43 | $instances = get_all_instances_in_course('rplace', $course); 44 | 45 | if (empty($instances)) { 46 | notice(get_string('thereareno', 'moodle', $modulenameplural), 47 | new moodle_url('/course/view.php', ['id' => $course->id])); 48 | } 49 | 50 | $table = new html_table(); 51 | $table->attributes['class'] = 'generaltable mod_index'; 52 | 53 | $usesections = course_format_uses_sections($course->format); 54 | 55 | $table = new html_table(); 56 | $table->attributes['class'] = 'generaltable mod_index'; 57 | 58 | if ($usesections) { 59 | $strsectionname = get_string('sectionname', 'format_'.$course->format); 60 | $table->head = [$strsectionname, get_string('name')]; 61 | $table->align = ['left', 'left']; 62 | } else { 63 | $table->head = [get_string('name')]; 64 | $table->align = ['left']; 65 | } 66 | 67 | foreach ($instances as $instance) { 68 | $attrs = $instance->visible ? [] : ['class' => 'dimmed']; // Hidden modules are dimmed. 69 | $link = html_writer::link( 70 | new moodle_url('/mod/rplace/view.php', ['id' => $instance->coursemodule]), 71 | format_string($instance->name, true), 72 | $attrs); 73 | 74 | if ($usesections) { 75 | $table->data[] = [$instance->section, $link]; 76 | } else { 77 | $table->data[] = [$link]; 78 | } 79 | } 80 | 81 | echo html_writer::table($table); 82 | echo $OUTPUT->footer(); 83 | -------------------------------------------------------------------------------- /classes/external/paint.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace\external; 18 | 19 | use core_external\external_function_parameters; 20 | use core_external\external_single_structure; 21 | use core_external\external_api; 22 | use core_external\external_value; 23 | use mod_rplace\api; 24 | 25 | /** 26 | * Implementation of web service mod_rplace_paint 27 | * 28 | * @package mod_rplace 29 | * @copyright 2024 Marina Glancy 30 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 31 | */ 32 | class paint extends external_api { 33 | 34 | /** 35 | * Describes the parameters for mod_rplace_paint 36 | * 37 | * @return external_function_parameters 38 | */ 39 | public static function execute_parameters(): external_function_parameters { 40 | return new external_function_parameters([ 41 | 'cmid' => new external_value(PARAM_INT, 'Course module id'), 42 | 'x' => new external_value(PARAM_INT, 'X position'), 43 | 'y' => new external_value(PARAM_INT, 'Y position'), 44 | 'color' => new external_value(PARAM_INT, 'Color'), 45 | ]); 46 | } 47 | 48 | /** 49 | * Implementation of web service mod_rplace_paint - user drawing a pixel 50 | * 51 | * @param int $cmid 52 | * @param int $x 53 | * @param int $y 54 | * @param int $color 55 | */ 56 | public static function execute($cmid, $x, $y, $color) { 57 | global $DB; 58 | 59 | // Parameter validation. 60 | ['cmid' => $cmid, 'x' => $x, 'y' => $y, 'color' => $color] = self::validate_parameters( 61 | self::execute_parameters(), 62 | ['cmid' => $cmid, 'x' => $x, 'y' => $y, 'color' => $color] 63 | ); 64 | 65 | [$course, $cm] = get_course_and_cm_from_cmid($cmid, 'rplace'); 66 | $context = \context_module::instance($cm->id); 67 | self::validate_context($context); 68 | 69 | if ($x == -1 && $y == -1) { 70 | require_capability('mod/rplace:clearall', $context); 71 | api::fill_all($cm, $color); 72 | } else { 73 | require_capability('mod/rplace:paint', $context); 74 | api::paint_a_pixel($cm, $x, $y, $color); 75 | } 76 | 77 | return []; 78 | } 79 | 80 | /** 81 | * Describe the return structure for mod_rplace_paint 82 | * 83 | * @return external_single_structure 84 | */ 85 | public static function execute_returns(): external_single_structure { 86 | return new external_single_structure([]); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /view.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * View Rplace instance 19 | * 20 | * @package mod_rplace 21 | * @copyright 2024 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | use mod_rplace\api; 26 | 27 | require(__DIR__.'/../../config.php'); 28 | require_once(__DIR__.'/lib.php'); 29 | 30 | // Course module id. 31 | $id = optional_param('id', 0, PARAM_INT); 32 | 33 | // Activity instance id. 34 | $r = optional_param('r', 0, PARAM_INT); 35 | 36 | if ($id) { 37 | $cm = get_coursemodule_from_id('rplace', $id, 0, false, MUST_EXIST); 38 | $course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); 39 | $moduleinstance = $DB->get_record('rplace', ['id' => $cm->instance], '*', MUST_EXIST); 40 | } else { 41 | $moduleinstance = $DB->get_record('rplace', ['id' => $r], '*', MUST_EXIST); 42 | $course = $DB->get_record('course', ['id' => $moduleinstance->course], '*', MUST_EXIST); 43 | $cm = get_coursemodule_from_instance('rplace', $moduleinstance->id, $course->id, false, MUST_EXIST); 44 | } 45 | 46 | require_login($course, true, $cm); 47 | 48 | \mod_rplace\event\course_module_viewed::create_from_record($moduleinstance, $cm, $course)->trigger(); 49 | 50 | $PAGE->set_url('/mod/rplace/view.php', ['id' => $cm->id]); 51 | $PAGE->set_title(format_string($moduleinstance->name)); 52 | $PAGE->set_heading(format_string($course->fullname)); 53 | 54 | // Subscribe to realtime notifications. 55 | (new \tool_realtime\channel($PAGE->context, 'mod_rplace', 'pattern', $PAGE->cm->id))->subscribe(); 56 | 57 | $PAGE->requires->js_call_amd('mod_rplace/rplace', 'init', [$cm->id, api::COLORS]); 58 | 59 | echo $OUTPUT->header(); 60 | 61 | if (has_capability('mod/rplace:paint', $PAGE->context)) { 62 | echo html_writer::tag('p', get_string('pickacolor', 'rplace') . ':'); 63 | echo api::display_color_picker(); 64 | } 65 | 66 | echo html_writer::tag('p', get_string('clicktodraw', 'rplace') . ':', ['class' => 'pt-4']); 67 | echo html_writer::div(api::display_canvas($moduleinstance, $PAGE->cm)); 68 | 69 | if (has_capability('mod/rplace:paint', $PAGE->context)) { 70 | echo html_writer::tag('p', 71 | html_writer::checkbox('instantfeedback', '1', true, 72 | get_string('instantfeedback', 'rplace'), 73 | ['data-purpose' => 'mod_rplace_instantfeedback', 'class' => 'mr-2']), 74 | ); 75 | } 76 | 77 | if (has_capability('mod/rplace:clearall', $PAGE->context)) { 78 | echo html_writer::tag('div', 79 | $OUTPUT->single_button('#', get_string('clearall', 'rplace'), 'get', ['data-action' => 'clearall']), 80 | ['class' => 'pt-2 mod_rplace_actions']); 81 | } 82 | 83 | echo $OUTPUT->footer(); 84 | -------------------------------------------------------------------------------- /backup/moodle2/restore_rplace_activity_task.class.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | defined('MOODLE_INTERNAL') || die(); 18 | 19 | require_once($CFG->dirroot . '/mod/rplace/backup/moodle2/restore_rplace_stepslib.php'); 20 | 21 | /** 22 | * Testore task that provides all the settings and steps to perform one complete restore of the activity 23 | * 24 | * @package mod_rplace 25 | * @copyright 2024 Marina Glancy 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | class restore_rplace_activity_task extends restore_activity_task { 29 | 30 | /** 31 | * Define (add) particular settings this activity can have 32 | */ 33 | protected function define_my_settings() { 34 | // No particular settings for this activity. 35 | } 36 | 37 | /** 38 | * Define (add) particular steps this activity can have 39 | */ 40 | protected function define_my_steps() { 41 | $this->add_step(new restore_rplace_activity_structure_step('rplace_structure', 'rplace.xml')); 42 | } 43 | 44 | /** 45 | * Define the contents in the activity that must be processed by the link decoder 46 | * 47 | * @return array 48 | */ 49 | public static function define_decode_contents() { 50 | $contents = []; 51 | 52 | $contents[] = new restore_decode_content('rplace', ['intro'], 'rplace'); 53 | 54 | return $contents; 55 | } 56 | 57 | /** 58 | * Define the decoding rules for links belonging to the activity to be executed by the link decoder 59 | * 60 | * @return array 61 | */ 62 | public static function define_decode_rules() { 63 | $rules = []; 64 | 65 | $rules[] = new restore_decode_rule('RPLACEVIEWBYID', '/mod/rplace/view.php?id=$1', 'course_module'); 66 | $rules[] = new restore_decode_rule('RPLACEINDEX', '/mod/rplace/index.php?id=$1', 'course'); 67 | 68 | return $rules; 69 | } 70 | 71 | /** 72 | * Define the restoring rules for logs belonging to the activity to be executed by the link decoder. 73 | * 74 | * @return array 75 | */ 76 | public static function define_restore_log_rules() { 77 | $rules = []; 78 | 79 | $rules[] = new restore_log_rule('rplace', 'add', 'view.php?id={course_module}', '{rplace}'); 80 | $rules[] = new restore_log_rule('rplace', 'update', 'view.php?id={course_module}', '{rplace}'); 81 | $rules[] = new restore_log_rule('rplace', 'view', 'view.php?id={course_module}', '{rplace}'); 82 | 83 | return $rules; 84 | } 85 | 86 | /** 87 | * Define the restoring rules for course associated to the activity to be executed by the link decoder. 88 | * 89 | * @return array 90 | */ 91 | public static function define_restore_log_rules_for_course() { 92 | $rules = []; 93 | 94 | $rules[] = new restore_log_rule('rplace', 'view all', 'index.php?id={course}', null); 95 | 96 | return $rules; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/lib_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace; 18 | 19 | /** 20 | * Tests for Rplace 21 | * 22 | * @package mod_rplace 23 | * @category test 24 | * @copyright 2024 Marina Glancy 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | final class lib_test extends \advanced_testcase { 28 | 29 | /** 30 | * Test create and delete module 31 | * 32 | * @covers ::rplace_add_instance 33 | * @covers ::rplace_delete_instance 34 | * @return void 35 | */ 36 | public function test_create_delete_module(): void { 37 | global $DB; 38 | $this->resetAfterTest(); 39 | 40 | // Disable recycle bin so we are testing module deletion and not backup. 41 | set_config('coursebinenable', 0, 'tool_recyclebin'); 42 | 43 | // Create an instance of a module. 44 | $course = $this->getDataGenerator()->create_course(); 45 | $mod = $this->getDataGenerator()->create_module('rplace', ['course' => $course->id]); 46 | $cm = get_coursemodule_from_instance('rplace', $mod->id); 47 | 48 | // Assert it was created. 49 | $this->assertNotEmpty(\context_module::instance($mod->cmid)); 50 | $this->assertEquals($mod->id, $cm->instance); 51 | $this->assertEquals('rplace', $cm->modname); 52 | $this->assertEquals(1, $DB->count_records('rplace', ['id' => $mod->id])); 53 | $this->assertEquals(1, $DB->count_records('course_modules', ['id' => $cm->id])); 54 | 55 | // Delete module. 56 | course_delete_module($cm->id); 57 | $this->assertEquals(0, $DB->count_records('rplace', ['id' => $mod->id])); 58 | $this->assertEquals(0, $DB->count_records('course_modules', ['id' => $cm->id])); 59 | } 60 | 61 | /** 62 | * Test module backup and restore by duplicating it 63 | * 64 | * @covers \backup_rplace_activity_structure_step 65 | * @covers \restore_rplace_activity_structure_step 66 | * @return void 67 | */ 68 | public function test_backup_restore(): void { 69 | global $DB; 70 | $this->resetAfterTest(); 71 | $this->setAdminUser(); 72 | 73 | // Createa a module. 74 | $course = $this->getDataGenerator()->create_course(); 75 | $mod = $this->getDataGenerator()->create_module('rplace', 76 | ['course' => $course->id, 'name' => 'My test module']); 77 | $cm = get_coursemodule_from_instance('rplace', $mod->id); 78 | 79 | // Call duplicate_module - it will backup and restore this module. 80 | $cmnew = duplicate_module($course, $cm); 81 | 82 | $this->assertNotNull($cmnew); 83 | $this->assertGreaterThan($cm->id, $cmnew->id); 84 | $this->assertGreaterThan($mod->id, $cmnew->instance); 85 | $this->assertEquals('rplace', $cmnew->modname); 86 | 87 | $name = $DB->get_field('rplace', 'name', ['id' => $cmnew->instance]); 88 | $this->assertEquals('My test module (copy)', $name); 89 | // TODO: check other fields and related tables. 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /amd/build/rplace.min.js: -------------------------------------------------------------------------------- 1 | define("mod_rplace/rplace",["exports","core/ajax","core/pubsub","tool_realtime/events","core/notification"],(function(_exports,Ajax,PubSub,RealTimeEvents,Notification){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} 2 | /** 3 | * Allows to draw pattern 4 | * 5 | * @module mod_rplace/rplace 6 | * @copyright 2024 Marina Glancy 7 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 8 | */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,Ajax=_interopRequireWildcard(Ajax),PubSub=_interopRequireWildcard(PubSub),RealTimeEvents=_interopRequireWildcard(RealTimeEvents),Notification=_interopRequireWildcard(Notification);const SELECTORS_PATTERNTABLE=".mod_rplace_pattern",SELECTORS_PATTERNTD=".mod_rplace_pattern td",SELECTORS_CLICKABLEPATTERNTD=".mod_rplace_pattern.clickable td",SELECTORS_CHOOSERTD=".mod_rplace_chooser td",SELECTORS_CLICKABLECHOOSERTD=".mod_rplace_chooser.clickable td",SELECTORS_CLEARALL='.mod_rplace_actions [data-action="clearall"]',SELECTORS_FEEDBACKCHECKBOX='[data-purpose="mod_rplace_instantfeedback"]';let colors=["#ffffff","#000000"],currentColor=0;const setBgColor=(el,colorId)=>{let style="background-color: "+colors[colorId];"#000000"===colors[colorId]&&(style+="; color: #ffffff"),el.style=style},redraw=pattern=>{const patternrows=(""+pattern).split(/\n/);document.querySelectorAll(SELECTORS_PATTERNTD).forEach((el=>{var _patternrows$y;const x=parseInt(el.dataset.x),y=parseInt(el.dataset.y);let value=(null!==(_patternrows$y=patternrows[y])&&void 0!==_patternrows$y?_patternrows$y:"").charCodeAt(x);value=(isNaN(value)?48:value)-48,setBgColor(el,value)}))},setCurrentColor=colorId=>{document.querySelectorAll(SELECTORS_CLICKABLECHOOSERTD).forEach((el=>{const id=parseInt(el.dataset.id);el.innerHTML=id===colorId?"X":" "})),currentColor=colorId};_exports.init=(cmid,colorset)=>{colors=colorset,setCurrentColor(Math.floor(Math.random()*colors.length));const patternTable=document.querySelector(SELECTORS_PATTERNTABLE);if(!patternTable)return;const initalpattern=patternTable.dataset.pattern;redraw(initalpattern),document.querySelectorAll(SELECTORS_CHOOSERTD).forEach((el=>{setBgColor(el,parseInt(el.dataset.id))})),document.querySelectorAll(SELECTORS_CLICKABLEPATTERNTD).forEach((el=>{el.addEventListener("click",(e=>{var _document$querySelect;e.preventDefault(),null!==(_document$querySelect=document.querySelector(SELECTORS_FEEDBACKCHECKBOX))&&void 0!==_document$querySelect&&_document$querySelect.checked&&setBgColor(el,currentColor),Ajax.call([{methodname:"mod_rplace_paint",args:{cmid:parseInt(cmid),x:parseInt(el.dataset.x),y:parseInt(el.dataset.y),color:currentColor}}])}))})),document.querySelectorAll(SELECTORS_CLICKABLECHOOSERTD).forEach((el=>{el.addEventListener("click",(e=>{e.preventDefault(),setCurrentColor(parseInt(el.dataset.id))}))})),PubSub.subscribe(RealTimeEvents.CONNECTION_LOST,(e=>{window.console.log("Error",e),Notification.exception({name:"Error",message:"Something went wrong, please refresh the page"})})),PubSub.subscribe(RealTimeEvents.EVENT,(data=>{const{component:component,area:area,itemid:itemid,payload:payload}=data;if(!payload||"mod_rplace"!=component||"pattern"!=area||itemid!=cmid)return;const updates=data.payload.updates,el=updates?document.querySelector(SELECTORS_CLICKABLEPATTERNTD+'[data-x="'.concat(updates.x,'"][data-y="').concat(updates.y,'"]')):null;if(el)setBgColor(el,updates.color);else{const pattern=data.payload.pattern;pattern&&redraw(pattern)}})),document.querySelectorAll(SELECTORS_CLEARALL).forEach((el=>{el.addEventListener("click",(e=>{e.preventDefault(),Ajax.call([{methodname:"mod_rplace_paint",args:{cmid:parseInt(cmid),x:-1,y:-1,color:0}}])}))}))}})); 9 | 10 | //# sourceMappingURL=rplace.min.js.map -------------------------------------------------------------------------------- /lib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Callback implementations for Rplace 19 | * 20 | * Documentation: {@link https://moodledev.io/docs/apis/plugintypes/mod} 21 | * 22 | * @package mod_rplace 23 | * @copyright 2024 Marina Glancy 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | 27 | /** 28 | * List of features supported in module 29 | * 30 | * @param string $feature FEATURE_xx constant for requested feature 31 | * @return mixed True if module supports feature, false if not, null if doesn't know or string for the module purpose. 32 | */ 33 | function rplace_supports($feature) { 34 | switch ($feature) { 35 | case FEATURE_MOD_INTRO: 36 | return true; 37 | case FEATURE_SHOW_DESCRIPTION: 38 | return true; 39 | case FEATURE_BACKUP_MOODLE2: 40 | return true; 41 | case FEATURE_MOD_PURPOSE: 42 | return MOD_PURPOSE_CONTENT; 43 | default: 44 | return null; 45 | } 46 | } 47 | 48 | /** 49 | * Add Rplace instance 50 | * 51 | * Given an object containing all the necessary data, (defined by the form in mod_form.php) 52 | * this function will create a new instance and return the id of the instance 53 | * 54 | * @param stdClass $moduleinstance form data 55 | * @param mod_rplace_mod_form $form the form 56 | * @return int new instance id 57 | */ 58 | function rplace_add_instance($moduleinstance, $form = null) { 59 | global $DB; 60 | 61 | $moduleinstance->timecreated = time(); 62 | $moduleinstance->timemodified = time(); 63 | 64 | $id = $DB->insert_record('rplace', $moduleinstance); 65 | $completiontimeexpected = !empty($moduleinstance->completionexpected) ? $moduleinstance->completionexpected : null; 66 | \core_completion\api::update_completion_date_event($moduleinstance->coursemodule, 67 | 'rplace', $id, $completiontimeexpected); 68 | return $id; 69 | } 70 | 71 | /** 72 | * Updates an instance of the Rplace in the database. 73 | * 74 | * Given an object containing all the necessary data (defined in mod_form.php), 75 | * this function will update an existing instance with new data. 76 | * 77 | * @param stdClass $moduleinstance An object from the form in mod_form.php 78 | * @param mod_rplace_mod_form $form The form 79 | * @return bool True if successful, false otherwis 80 | */ 81 | function rplace_update_instance($moduleinstance, $form = null) { 82 | global $DB; 83 | 84 | $moduleinstance->timemodified = time(); 85 | $moduleinstance->id = $moduleinstance->instance; 86 | 87 | $DB->update_record('rplace', $moduleinstance); 88 | 89 | $completiontimeexpected = !empty($moduleinstance->completionexpected) ? $moduleinstance->completionexpected : null; 90 | \core_completion\api::update_completion_date_event($moduleinstance->coursemodule, 'rplace', 91 | $moduleinstance->id, $completiontimeexpected); 92 | 93 | return true; 94 | } 95 | 96 | /** 97 | * Removes an instance of the Rplace from the database. 98 | * 99 | * @param int $id Id of the module instance 100 | * @return bool True if successful, false otherwise 101 | */ 102 | function rplace_delete_instance($id) { 103 | global $DB; 104 | 105 | $record = $DB->get_record('rplace', ['id' => $id]); 106 | if (!$record) { 107 | return false; 108 | } 109 | 110 | // Delete all calendar events. 111 | $events = $DB->get_records('event', ['modulename' => 'rplace', 'instance' => $record->id]); 112 | foreach ($events as $event) { 113 | calendar_event::load($event)->delete(); 114 | } 115 | 116 | // Delete the instance. 117 | $DB->delete_records('rplace', ['id' => $id]); 118 | 119 | return true; 120 | } 121 | 122 | /** 123 | * Check if the module has any update that affects the current user since a given time. 124 | * 125 | * @param cm_info $cm course module data 126 | * @param int $from the time to check updates from 127 | * @param array $filter if we need to check only specific updates 128 | * @return stdClass an object with the different type of areas indicating if they were updated or not 129 | */ 130 | function mod_rplace_check_updates_since(cm_info $cm, $from, $filter = []) { 131 | $updates = course_check_module_updates_since($cm, $from, ['content'], $filter); 132 | return $updates; 133 | } 134 | -------------------------------------------------------------------------------- /amd/src/rplace.js: -------------------------------------------------------------------------------- 1 | // This file is part of Moodle - http://moodle.org/ 2 | // 3 | // Moodle is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // Moodle is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with Moodle. If not, see . 15 | 16 | /** 17 | * Allows to draw pattern 18 | * 19 | * @module mod_rplace/rplace 20 | * @copyright 2024 Marina Glancy 21 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 22 | */ 23 | 24 | import * as Ajax from 'core/ajax'; 25 | import * as PubSub from 'core/pubsub'; 26 | import * as RealTimeEvents from 'tool_realtime/events'; 27 | import * as Notification from 'core/notification'; 28 | 29 | const SELECTORS = { 30 | PATTERNTABLE: '.mod_rplace_pattern', 31 | CHOOSERTABLE: '.mod_rplace_chooser', 32 | PATTERNTD: '.mod_rplace_pattern td', 33 | CLICKABLEPATTERNTD: '.mod_rplace_pattern.clickable td', 34 | CHOOSERTD: '.mod_rplace_chooser td', 35 | CLICKABLECHOOSERTD: '.mod_rplace_chooser.clickable td', 36 | CLEARALL: '.mod_rplace_actions [data-action="clearall"]', 37 | FEEDBACKCHECKBOX: '[data-purpose="mod_rplace_instantfeedback"]', 38 | }; 39 | 40 | let colors = ['#ffffff', '#000000']; 41 | let currentColor = 0; 42 | 43 | const setBgColor = (el, colorId) => { 44 | let style = 'background-color: ' + colors[colorId]; 45 | if (colors[colorId] === '#000000') { 46 | style += '; color: #ffffff'; 47 | } 48 | el.style = style; 49 | }; 50 | 51 | const redraw = (pattern) => { 52 | const patternrows = ('' + pattern).split(/\n/); 53 | document.querySelectorAll(SELECTORS.PATTERNTD).forEach(el => { 54 | const x = parseInt(el.dataset.x); 55 | const y = parseInt(el.dataset.y); 56 | let value = (patternrows[y] ?? '').charCodeAt(x); 57 | value = (isNaN(value) ? 48 : value) - 48; 58 | setBgColor(el, value); 59 | }); 60 | }; 61 | 62 | const setCurrentColor = (colorId) => { 63 | document.querySelectorAll(SELECTORS.CLICKABLECHOOSERTD).forEach(el => { 64 | const id = parseInt(el.dataset.id); 65 | el.innerHTML = (id === colorId) ? 'X' : ' '; 66 | }); 67 | currentColor = colorId; 68 | }; 69 | 70 | export const init = (cmid, colorset) => { 71 | colors = colorset; 72 | setCurrentColor(Math.floor(Math.random() * colors.length)); 73 | 74 | const patternTable = document.querySelector(SELECTORS.PATTERNTABLE); 75 | if (!patternTable) { 76 | return; 77 | } 78 | 79 | const initalpattern = patternTable.dataset.pattern; 80 | redraw(initalpattern); 81 | 82 | document.querySelectorAll(SELECTORS.CHOOSERTD).forEach(el => { 83 | setBgColor(el, parseInt(el.dataset.id)); 84 | }); 85 | 86 | document.querySelectorAll(SELECTORS.CLICKABLEPATTERNTD).forEach(el => { 87 | el.addEventListener('click', (e) => { 88 | e.preventDefault(); 89 | if (document.querySelector(SELECTORS.FEEDBACKCHECKBOX)?.checked) { 90 | // Change the cell color instantly, without waiting for update from server. 91 | setBgColor(el, currentColor); 92 | } 93 | Ajax.call([{ 94 | methodname: 'mod_rplace_paint', 95 | args: { 96 | cmid: parseInt(cmid), x: parseInt(el.dataset.x), y: parseInt(el.dataset.y), color: currentColor 97 | } 98 | }]); 99 | }); 100 | }); 101 | 102 | document.querySelectorAll(SELECTORS.CLICKABLECHOOSERTD).forEach(el => { 103 | el.addEventListener('click', (e) => { 104 | e.preventDefault(); 105 | setCurrentColor(parseInt(el.dataset.id)); 106 | }); 107 | }); 108 | 109 | PubSub.subscribe(RealTimeEvents.CONNECTION_LOST, (e) => { 110 | window.console.log('Error', e); 111 | Notification.exception({ 112 | name: 'Error', 113 | message: 'Something went wrong, please refresh the page'}); 114 | }); 115 | 116 | PubSub.subscribe(RealTimeEvents.EVENT, (data) => { 117 | const {component, area, itemid, payload} = data; 118 | if (!payload || component != 'mod_rplace' || area != 'pattern' || itemid != cmid) { 119 | return; 120 | } 121 | 122 | const updates = data.payload.updates; 123 | const el = updates ? document.querySelector(SELECTORS.CLICKABLEPATTERNTD + 124 | `[data-x="${updates.x}"][data-y="${updates.y}"]`) : null; 125 | if (el) { 126 | setBgColor(el, updates.color); 127 | } else { 128 | const pattern = data.payload.pattern; 129 | if (pattern) { 130 | redraw(pattern); 131 | } 132 | } 133 | }); 134 | 135 | document.querySelectorAll(SELECTORS.CLEARALL).forEach(el => { 136 | el.addEventListener('click', (e) => { 137 | e.preventDefault(); 138 | Ajax.call([{ 139 | methodname: 'mod_rplace_paint', 140 | args: { 141 | cmid: parseInt(cmid), x: -1, y: -1, color: 0 142 | } 143 | }]); 144 | }); 145 | }); 146 | }; 147 | -------------------------------------------------------------------------------- /.github/workflows/gha.yml: -------------------------------------------------------------------------------- 1 | name: Moodle Plugin CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | 9 | services: 10 | postgres: 11 | image: postgres:13 12 | env: 13 | POSTGRES_USER: 'postgres' 14 | POSTGRES_HOST_AUTH_METHOD: 'trust' 15 | ports: 16 | - 5432:5432 17 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 18 | 19 | mariadb: 20 | image: mariadb:10 21 | env: 22 | MYSQL_USER: 'root' 23 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 24 | MYSQL_CHARACTER_SET_SERVER: "utf8mb4" 25 | MYSQL_COLLATION_SERVER: "utf8mb4_unicode_ci" 26 | ports: 27 | - 3306:3306 28 | options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | include: 34 | - php: '8.1' 35 | # Main job. Run all checks that do not require setup and only need to be run once. 36 | runchecks: 'all' 37 | moodle-branch: 'MOODLE_401_STABLE' 38 | database: 'pgsql' 39 | - php: '7.4' 40 | moodle-branch: 'MOODLE_401_STABLE' 41 | database: 'mariadb' 42 | - php: '8.2' 43 | moodle-branch: 'MOODLE_402_STABLE' 44 | database: 'mariadb' 45 | - php: '8.0' 46 | moodle-branch: 'MOODLE_402_STABLE' 47 | database: 'pgsql' 48 | - php: '8.2' 49 | moodle-branch: 'MOODLE_403_STABLE' 50 | database: 'pgsql' 51 | - php: '8.0' 52 | moodle-branch: 'MOODLE_403_STABLE' 53 | database: 'mariadb' 54 | - php: '8.3' 55 | moodle-branch: 'MOODLE_404_STABLE' 56 | database: 'mariadb' 57 | - php: '8.1' 58 | moodle-branch: 'MOODLE_404_STABLE' 59 | database: 'pgsql' 60 | - php: '8.3' 61 | moodle-branch: 'main' 62 | database: 'pgsql' 63 | - php: '8.1' 64 | moodle-branch: 'main' 65 | database: 'mariadb' 66 | 67 | steps: 68 | - name: Check out repository code 69 | uses: actions/checkout@v4 70 | with: 71 | path: plugin 72 | 73 | - name: Setup PHP ${{ matrix.php }} 74 | uses: shivammathur/setup-php@v2 75 | with: 76 | php-version: ${{ matrix.php }} 77 | extensions: ${{ matrix.extensions }} 78 | ini-values: max_input_vars=5000 79 | # If you are not using code coverage, keep "none". Otherwise, use "pcov" (Moodle 3.10 and up) or "xdebug". 80 | # If you try to use code coverage with "none", it will fallback to phpdbg (which has known problems). 81 | coverage: none 82 | 83 | - name: Initialise moodle-plugin-ci 84 | run: | 85 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 86 | echo $(cd ci/bin; pwd) >> $GITHUB_PATH 87 | echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH 88 | sudo locale-gen en_AU.UTF-8 89 | echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV 90 | 91 | - name: Install moodle-plugin-ci 92 | run: | 93 | moodle-plugin-ci add-plugin marinaglancy/moodle-tool_realtime 94 | moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 95 | env: 96 | DB: ${{ matrix.database }} 97 | MOODLE_BRANCH: ${{ matrix.moodle-branch }} 98 | # Uncomment this to run Behat tests using the Moodle App. 99 | # MOODLE_APP: 'true' 100 | 101 | - name: PHP Lint 102 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 103 | run: moodle-plugin-ci phplint 104 | 105 | - name: PHP Mess Detector 106 | continue-on-error: true # This step will show errors but will not fail 107 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 108 | run: moodle-plugin-ci phpmd 109 | 110 | - name: Moodle Code Checker 111 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 112 | run: moodle-plugin-ci phpcs --max-warnings 0 113 | 114 | - name: Moodle PHPDoc Checker 115 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 116 | run: moodle-plugin-ci phpdoc --max-warnings 0 117 | 118 | - name: Validating 119 | if: ${{ !cancelled() }} 120 | run: moodle-plugin-ci validate 121 | 122 | - name: Check upgrade savepoints 123 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 124 | run: moodle-plugin-ci savepoints 125 | 126 | - name: Mustache Lint 127 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 128 | run: moodle-plugin-ci mustache 129 | 130 | - name: Grunt 131 | if: ${{ !cancelled() && matrix.runchecks == 'all' }} 132 | run: moodle-plugin-ci grunt --max-lint-warnings 0 133 | 134 | - name: PHPUnit tests 135 | if: ${{ !cancelled() }} 136 | run: moodle-plugin-ci phpunit --fail-on-warning 137 | 138 | - name: Behat features 139 | id: behat 140 | if: ${{ !cancelled() }} 141 | run: moodle-plugin-ci behat --profile chrome 142 | 143 | - name: Upload Behat Faildump 144 | if: ${{ failure() && steps.behat.outcome == 'failure' }} 145 | uses: actions/upload-artifact@v4 146 | with: 147 | name: Behat Faildump (${{ join(matrix.*, ', ') }}) 148 | path: ${{ github.workspace }}/moodledata/behat_dump 149 | retention-days: 7 150 | if-no-files-found: ignore 151 | 152 | - name: Mark cancelled jobs as failed. 153 | if: ${{ cancelled() }} 154 | run: exit 1 155 | -------------------------------------------------------------------------------- /classes/api.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace mod_rplace; 18 | 19 | use html_writer; 20 | use tool_realtime\channel; 21 | 22 | /** 23 | * Class api 24 | * 25 | * @package mod_rplace 26 | * @copyright 2024 Marina Glancy 27 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 28 | */ 29 | class api { 30 | /** @var array Available colors */ 31 | const COLORS = [ 32 | '#ffffff', 33 | '#be0039', 34 | '#ff4500', 35 | '#ffa800', 36 | '#ffd635', 37 | '#00a368', 38 | '#00cc78', 39 | '#7eed56', 40 | '#00756f', 41 | '#009eaa', 42 | '#2450a4', 43 | '#3690ea', 44 | '#51e9f4', 45 | '#493ac1', 46 | '#6a5cff', 47 | '#811e9f', 48 | '#b44ac0', 49 | '#ff3881', 50 | '#ff99aa', 51 | '#6d482f', 52 | '#9c6926', 53 | '#000000', 54 | '#898d90', 55 | '#d4d7d9', 56 | ]; 57 | /** @var int width or the canvas */ 58 | const WIDTH = 30; 59 | /** @var int height or the canvas */ 60 | const HEIGHT = 30; 61 | 62 | /** 63 | * Display list of colors to pick from 64 | * 65 | * @return string 66 | */ 67 | public static function display_color_picker(): string { 68 | $rv = ''; 69 | for ($i = 0; $i < count(self::COLORS); $i++) { 70 | $rv .= html_writer::tag('td', ' ', ['data-id' => $i]); 71 | } 72 | $rv .= '
'; 73 | return $rv; 74 | } 75 | 76 | /** 77 | * Display the canvas 78 | * 79 | * @param \stdClass $activityrecord 80 | * @param \cm_info $cm 81 | * @return string 82 | */ 83 | public static function display_canvas(\stdClass $activityrecord, \cm_info $cm): string { 84 | $rv = html_writer::start_tag('table', [ 85 | 'class' => 'mod_rplace_pattern clickable', 86 | 'data-pattern' => $activityrecord->pattern ?? '', 87 | ]); 88 | for ($row = 0; $row < self::HEIGHT; $row++) { 89 | $rv .= ''; 90 | for ($col = 0; $col < self::WIDTH; $col++) { 91 | $rv .= html_writer::tag('td', ' ', ['data-x' => $col, 'data-y' => $row]); 92 | } 93 | $rv .= ''; 94 | } 95 | $rv .= ''; 96 | return $rv; 97 | } 98 | 99 | /** 100 | * Return the pattern as array 101 | * 102 | * @param \cm_info $cm 103 | * @return array 104 | */ 105 | protected static function get_pattern(\cm_info $cm): array { 106 | global $DB; 107 | 108 | $instance = $DB->get_record('rplace', ['id' => $cm->instance], 'id, pattern', MUST_EXIST); 109 | $pattern = $instance->pattern ?? ''; 110 | $values = []; 111 | 112 | $patternrows = preg_split("/\\n/", $pattern); 113 | for ($i = 0; $i < self::HEIGHT; $i++) { 114 | $values[$i] = []; 115 | $patternrow = $i < count($patternrows) ? $patternrows[$i] : ''; 116 | for ($j = 0; $j < self::WIDTH; $j++) { 117 | $value = strlen($patternrow) > $j ? ord(substr($patternrow, $j, 1)) - ord('0') : 0; 118 | $value = max(0, min($value, count(self::COLORS) - 1)); 119 | $values[$i][$j] = $value; 120 | } 121 | } 122 | return $values; 123 | } 124 | 125 | /** 126 | * Called from WS when user paints a pixel 127 | * 128 | * @param \cm_info $cm 129 | * @param int $x 130 | * @param int $y 131 | * @param int $color 132 | * @return void 133 | */ 134 | public static function paint_a_pixel(\cm_info $cm, int $x, int $y, int $color): void { 135 | global $DB; 136 | 137 | if ($x < 0 || $x >= self::WIDTH || $y < 0 || $y >= self::HEIGHT || $color < 0 || $color >= count(self::COLORS)) { 138 | return; 139 | } 140 | 141 | $values = self::get_pattern($cm); 142 | 143 | $newpattern = ''; 144 | for ($i = 0; $i < self::HEIGHT; $i++) { 145 | for ($j = 0; $j < self::WIDTH; $j++) { 146 | $value = ($j == $x && $i == $y) ? $color : $values[$i][$j]; 147 | $newpattern .= chr(ord('0') + $value); 148 | } 149 | $newpattern .= "\n"; 150 | } 151 | 152 | $DB->set_field('rplace', 'pattern', $newpattern, ['id' => $cm->instance]); 153 | \mod_rplace\event\pattern_updated::create_from_coordinates($cm, $x, $y, $color); 154 | $payload = [ 155 | 'updates' => ['x' => $x, 'y' => $y, 'color' => $color], 156 | ]; 157 | $context = \context_module::instance($cm->id); 158 | 159 | // Notify all subscribers about the event in real time. 160 | (new channel($context, 'mod_rplace', 'pattern', $cm->id))->notify($payload); 161 | } 162 | 163 | /** 164 | * Called from WS when user paints a pixel 165 | * 166 | * @param \cm_info $cm 167 | * @param int $color 168 | * @return void 169 | */ 170 | public static function fill_all(\cm_info $cm, int $color): void { 171 | global $DB; 172 | 173 | if ($color < 0 || $color >= count(self::COLORS)) { 174 | return; 175 | } 176 | 177 | $values = self::get_pattern($cm); 178 | for ($i = 0; $i < self::HEIGHT; $i++) { 179 | for ($j = 0; $j < self::WIDTH; $j++) { 180 | if ($values[$i][$j] != $color) { 181 | self::paint_a_pixel($cm, $j, $i, $color); 182 | } 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /amd/build/rplace.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"rplace.min.js","sources":["../src/rplace.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Allows to draw pattern\n *\n * @module mod_rplace/rplace\n * @copyright 2024 Marina Glancy\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as Ajax from 'core/ajax';\nimport * as PubSub from 'core/pubsub';\nimport * as RealTimeEvents from 'tool_realtime/events';\nimport * as Notification from 'core/notification';\n\nconst SELECTORS = {\n PATTERNTABLE: '.mod_rplace_pattern',\n CHOOSERTABLE: '.mod_rplace_chooser',\n PATTERNTD: '.mod_rplace_pattern td',\n CLICKABLEPATTERNTD: '.mod_rplace_pattern.clickable td',\n CHOOSERTD: '.mod_rplace_chooser td',\n CLICKABLECHOOSERTD: '.mod_rplace_chooser.clickable td',\n CLEARALL: '.mod_rplace_actions [data-action=\"clearall\"]',\n FEEDBACKCHECKBOX: '[data-purpose=\"mod_rplace_instantfeedback\"]',\n};\n\nlet colors = ['#ffffff', '#000000'];\nlet currentColor = 0;\n\nconst setBgColor = (el, colorId) => {\n let style = 'background-color: ' + colors[colorId];\n if (colors[colorId] === '#000000') {\n style += '; color: #ffffff';\n }\n el.style = style;\n};\n\nconst redraw = (pattern) => {\n const patternrows = ('' + pattern).split(/\\n/);\n document.querySelectorAll(SELECTORS.PATTERNTD).forEach(el => {\n const x = parseInt(el.dataset.x);\n const y = parseInt(el.dataset.y);\n let value = (patternrows[y] ?? '').charCodeAt(x);\n value = (isNaN(value) ? 48 : value) - 48;\n setBgColor(el, value);\n });\n};\n\nconst setCurrentColor = (colorId) => {\n document.querySelectorAll(SELECTORS.CLICKABLECHOOSERTD).forEach(el => {\n const id = parseInt(el.dataset.id);\n el.innerHTML = (id === colorId) ? 'X' : ' ';\n });\n currentColor = colorId;\n};\n\nexport const init = (cmid, colorset) => {\n colors = colorset;\n setCurrentColor(Math.floor(Math.random() * colors.length));\n\n const patternTable = document.querySelector(SELECTORS.PATTERNTABLE);\n if (!patternTable) {\n return;\n }\n\n const initalpattern = patternTable.dataset.pattern;\n redraw(initalpattern);\n\n document.querySelectorAll(SELECTORS.CHOOSERTD).forEach(el => {\n setBgColor(el, parseInt(el.dataset.id));\n });\n\n document.querySelectorAll(SELECTORS.CLICKABLEPATTERNTD).forEach(el => {\n el.addEventListener('click', (e) => {\n e.preventDefault();\n if (document.querySelector(SELECTORS.FEEDBACKCHECKBOX)?.checked) {\n // Change the cell color instantly, without waiting for update from server.\n setBgColor(el, currentColor);\n }\n Ajax.call([{\n methodname: 'mod_rplace_paint',\n args: {\n cmid: parseInt(cmid), x: parseInt(el.dataset.x), y: parseInt(el.dataset.y), color: currentColor\n }\n }]);\n });\n });\n\n document.querySelectorAll(SELECTORS.CLICKABLECHOOSERTD).forEach(el => {\n el.addEventListener('click', (e) => {\n e.preventDefault();\n setCurrentColor(parseInt(el.dataset.id));\n });\n });\n\n PubSub.subscribe(RealTimeEvents.CONNECTION_LOST, (e) => {\n window.console.log('Error', e);\n Notification.exception({\n name: 'Error',\n message: 'Something went wrong, please refresh the page'});\n });\n\n PubSub.subscribe(RealTimeEvents.EVENT, (data) => {\n const {component, area, itemid, payload} = data;\n if (!payload || component != 'mod_rplace' || area != 'pattern' || itemid != cmid) {\n return;\n }\n\n const updates = data.payload.updates;\n const el = updates ? document.querySelector(SELECTORS.CLICKABLEPATTERNTD +\n `[data-x=\"${updates.x}\"][data-y=\"${updates.y}\"]`) : null;\n if (el) {\n setBgColor(el, updates.color);\n } else {\n const pattern = data.payload.pattern;\n if (pattern) {\n redraw(pattern);\n }\n }\n });\n\n document.querySelectorAll(SELECTORS.CLEARALL).forEach(el => {\n el.addEventListener('click', (e) => {\n e.preventDefault();\n Ajax.call([{\n methodname: 'mod_rplace_paint',\n args: {\n cmid: parseInt(cmid), x: -1, y: -1, color: 0\n }\n }]);\n });\n });\n};\n"],"names":["SELECTORS","colors","currentColor","setBgColor","el","colorId","style","redraw","pattern","patternrows","split","document","querySelectorAll","forEach","x","parseInt","dataset","y","value","charCodeAt","isNaN","setCurrentColor","id","innerHTML","cmid","colorset","Math","floor","random","length","patternTable","querySelector","initalpattern","addEventListener","e","preventDefault","_document$querySelect","checked","Ajax","call","methodname","args","color","PubSub","subscribe","RealTimeEvents","CONNECTION_LOST","window","console","log","Notification","exception","name","message","EVENT","data","component","area","itemid","payload","updates"],"mappings":";;;;;;;4QA4BMA,uBACY,sBADZA,oBAGS,yBAHTA,6BAIkB,mCAJlBA,oBAKS,yBALTA,6BAMkB,mCANlBA,mBAOQ,+CAPRA,2BAQgB,kDAGlBC,OAAS,CAAC,UAAW,WACrBC,aAAe,QAEbC,WAAa,CAACC,GAAIC,eAChBC,MAAQ,qBAAuBL,OAAOI,SAClB,YAApBJ,OAAOI,WACPC,OAAS,oBAEbF,GAAGE,MAAQA,OAGTC,OAAUC,gBACNC,aAAe,GAAKD,SAASE,MAAM,MACzCC,SAASC,iBAAiBZ,qBAAqBa,SAAQT,8BAC7CU,EAAIC,SAASX,GAAGY,QAAQF,GACxBG,EAAIF,SAASX,GAAGY,QAAQC,OAC1BC,8BAAST,YAAYQ,4CAAM,IAAIE,WAAWL,GAC9CI,OAASE,MAAMF,OAAS,GAAKA,OAAS,GACtCf,WAAWC,GAAIc,WAIjBG,gBAAmBhB,UACrBM,SAASC,iBAAiBZ,8BAA8Ba,SAAQT,WACtDkB,GAAKP,SAASX,GAAGY,QAAQM,IAC/BlB,GAAGmB,UAAaD,KAAOjB,QAAW,IAAM,YAE5CH,aAAeG,uBAGC,CAACmB,KAAMC,YACvBxB,OAASwB,SACTJ,gBAAgBK,KAAKC,MAAMD,KAAKE,SAAW3B,OAAO4B,eAE5CC,aAAenB,SAASoB,cAAc/B,4BACvC8B,0BAICE,cAAgBF,aAAad,QAAQR,QAC3CD,OAAOyB,eAEPrB,SAASC,iBAAiBZ,qBAAqBa,SAAQT,KACnDD,WAAWC,GAAIW,SAASX,GAAGY,QAAQM,QAGvCX,SAASC,iBAAiBZ,8BAA8Ba,SAAQT,KAC5DA,GAAG6B,iBAAiB,SAAUC,8BAC1BA,EAAEC,+CACExB,SAASoB,cAAc/B,8DAAvBoC,sBAAoDC,SAEpDlC,WAAWC,GAAIF,cAEnBoC,KAAKC,KAAK,CAAC,CACPC,WAAY,mBACZC,KAAM,CACFjB,KAAMT,SAASS,MAAOV,EAAGC,SAASX,GAAGY,QAAQF,GAAIG,EAAGF,SAASX,GAAGY,QAAQC,GAAIyB,MAAOxC,uBAMnGS,SAASC,iBAAiBZ,8BAA8Ba,SAAQT,KAC5DA,GAAG6B,iBAAiB,SAAUC,IAC1BA,EAAEC,iBACFd,gBAAgBN,SAASX,GAAGY,QAAQM,WAI5CqB,OAAOC,UAAUC,eAAeC,iBAAkBZ,IAC9Ca,OAAOC,QAAQC,IAAI,QAASf,GAC5BgB,aAAaC,UAAU,CACnBC,KAAM,QACNC,QAAS,qDAGjBV,OAAOC,UAAUC,eAAeS,OAAQC,aAC9BC,UAACA,UAADC,KAAYA,KAAZC,OAAkBA,OAAlBC,QAA0BA,SAAWJ,SACtCI,SAAwB,cAAbH,WAAqC,WAARC,MAAqBC,QAAUlC,kBAItEoC,QAAUL,KAAKI,QAAQC,QACvBxD,GAAKwD,QAAUjD,SAASoB,cAAc/B,gDAC5B4D,QAAQ9C,wBAAe8C,QAAQ3C,SAAS,QACpDb,GACAD,WAAWC,GAAIwD,QAAQlB,WACpB,OACGlC,QAAU+C,KAAKI,QAAQnD,QACzBA,SACAD,OAAOC,aAKnBG,SAASC,iBAAiBZ,oBAAoBa,SAAQT,KAClDA,GAAG6B,iBAAiB,SAAUC,IAC1BA,EAAEC,iBACFG,KAAKC,KAAK,CAAC,CACPC,WAAY,mBACZC,KAAM,CACFjB,KAAMT,SAASS,MAAOV,GAAI,EAAGG,GAAI,EAAGyB,MAAO"} --------------------------------------------------------------------------------