├── .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 |
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"}
--------------------------------------------------------------------------------