Merge branch 'MDL-67806-master' of git://github.com/dpalou/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 25 Feb 2020 22:57:15 +0000 (23:57 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 25 Feb 2020 22:57:15 +0000 (23:57 +0100)
506 files changed:
Gruntfile.js
GruntfileComponents.js
admin/settings/courses.php
admin/settings/h5p.php
admin/settings/security.php
admin/tool/behat/index.php
admin/tool/behat/locallib.php
admin/tool/behat/renderer.php
admin/tool/behat/steps_definitions_form.php
admin/tool/behat/styles.css
admin/tool/behat/tests/behat/list_steps.feature
admin/tool/capability/renderer.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/phpunit/settings.php
admin/tool/phpunit/upgrade.txt [new file with mode: 0644]
admin/tool/phpunit/webrunner.php [deleted file]
admin/user/user_bulk.php
admin/user/user_bulk_forms.php
auth/shibboleth/auth.php
auth/shibboleth/index.php
auth/shibboleth/lang/en/auth_shibboleth.php
auth/shibboleth/settings.php
badges/classes/form/badge.php
badges/lib/bakerlib.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/db/upgrade.php
blocks/myoverview/lib.php
blocks/myoverview/settings.php
blocks/myoverview/templates/nav-grouping-selector.mustache
blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
blocks/myoverview/tests/myoverview_test.php
blocks/myoverview/upgrade.txt
blocks/myoverview/version.php
blocks/site_main_menu/tests/behat/add_url.feature
calendar/view.php
competency/classes/api.php
competency/tests/hooks_test.php
config-dist.php
course/amd/build/activitychooser.min.js [new file with mode: 0644]
course/amd/build/activitychooser.min.js.map [new file with mode: 0644]
course/amd/build/local/activitychooser/dialogue.min.js [new file with mode: 0644]
course/amd/build/local/activitychooser/dialogue.min.js.map [new file with mode: 0644]
course/amd/build/local/activitychooser/repository.min.js [new file with mode: 0644]
course/amd/build/local/activitychooser/repository.min.js.map [new file with mode: 0644]
course/amd/build/local/activitychooser/selectors.min.js [new file with mode: 0644]
course/amd/build/local/activitychooser/selectors.min.js.map [new file with mode: 0644]
course/amd/build/recommendations.min.js [new file with mode: 0644]
course/amd/build/recommendations.min.js.map [new file with mode: 0644]
course/amd/src/activitychooser.js [new file with mode: 0644]
course/amd/src/local/activitychooser/dialogue.js [new file with mode: 0644]
course/amd/src/local/activitychooser/repository.js [new file with mode: 0644]
course/amd/src/local/activitychooser/selectors.js [new file with mode: 0644]
course/amd/src/recommendations.js [new file with mode: 0644]
course/classes/category.php
course/classes/customfield/course_handler.php
course/classes/local/entity/content_item.php [new file with mode: 0644]
course/classes/local/entity/lang_string_title.php [new file with mode: 0644]
course/classes/local/entity/string_title.php [new file with mode: 0644]
course/classes/local/entity/title.php [new file with mode: 0644]
course/classes/local/exporters/course_content_item_exporter.php [new file with mode: 0644]
course/classes/local/exporters/course_content_items_exporter.php [new file with mode: 0644]
course/classes/local/factory/content_item_service_factory.php [new file with mode: 0644]
course/classes/local/repository/caching_content_item_readonly_repository.php [new file with mode: 0644]
course/classes/local/repository/content_item_readonly_repository.php [new file with mode: 0644]
course/classes/local/repository/content_item_readonly_repository_interface.php [new file with mode: 0644]
course/classes/local/service/content_item_service.php [new file with mode: 0644]
course/classes/output/modchooser.php [deleted file]
course/classes/output/modchooser_item.php [deleted file]
course/classes/output/recommendations/activity_list.php [new file with mode: 0644]
course/classes/output/recommendations/renderer.php [new file with mode: 0644]
course/classes/privacy/provider.php
course/externallib.php
course/format/renderer.php
course/format/singleactivity/lib.php
course/lib.php
course/moodleform_mod.php
course/recommendations.php [new file with mode: 0644]
course/renderer.php
course/templates/activity_list.mustache [new file with mode: 0644]
course/templates/chooser.mustache [new file with mode: 0644]
course/templates/chooser_help.mustache [new file with mode: 0644]
course/templates/chooser_item.mustache [new file with mode: 0644]
course/templates/modchooser.mustache [deleted file]
course/tests/behat/activity_chooser.feature [new file with mode: 0644]
course/tests/behat/behat_course.php
course/tests/behat/recommend_activities.feature [new file with mode: 0644]
course/tests/caching_content_item_readonly_repository_test.php [new file with mode: 0644]
course/tests/content_item_readonly_repository_test.php [new file with mode: 0644]
course/tests/content_item_test.php [new file with mode: 0644]
course/tests/courselib_test.php
course/tests/exporters_content_item_test.php [new file with mode: 0644]
course/tests/exporters_content_items_test.php [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/services_content_item_service_test.php [new file with mode: 0644]
course/upgrade.txt
course/yui/build/moodle-course-modchooser/moodle-course-modchooser-debug.js [deleted file]
course/yui/build/moodle-course-modchooser/moodle-course-modchooser-min.js [deleted file]
course/yui/build/moodle-course-modchooser/moodle-course-modchooser.js [deleted file]
course/yui/src/modchooser/build.json [deleted file]
course/yui/src/modchooser/js/modchooser.js [deleted file]
course/yui/src/modchooser/meta/modchooser.json [deleted file]
customfield/tests/api_test.php
customfield/tests/category_controller_test.php
customfield/tests/data_controller_test.php
customfield/tests/field_controller_test.php
customfield/tests/generator_test.php
customfield/tests/privacy_test.php
favourites/tests/component_favourite_service_test.php
favourites/tests/user_favourite_service_test.php
h5p/classes/core.php
h5p/classes/file_storage.php
h5p/classes/form/uploadlibraries_form.php
h5p/classes/helper.php
h5p/classes/player.php
h5p/libraries.php
h5p/overview.php [new file with mode: 0644]
h5p/templates/h5plibraries.mustache
h5p/templates/h5ptoolsoverview.mustache [new file with mode: 0644]
h5p/tests/behat/h5p_libraries.feature
h5p/tests/behat/h5p_overview.feature [new file with mode: 0644]
h5p/tests/external_test.php
h5p/tests/h5p_core_test.php
h5p/tests/h5p_file_storage_test.php
install/lang/el/error.php
install/lang/es_wp/langconfig.php
install/lang/he_kids/langconfig.php
install/lang/he_wp/admin.php [new file with mode: 0644]
install/lang/om/langconfig.php [new file with mode: 0644]
lang/en/admin.php
lang/en/cache.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/h5p.php
lang/en/moodle.php
lang/en/question.php
lang/en/role.php
lang/en/timezones.php
lang/en/webservice.php
lib/adminlib.php
lib/adodb/adodb-active-record.inc.php
lib/adodb/adodb-active-recordx.inc.php
lib/adodb/adodb-csvlib.inc.php
lib/adodb/adodb-datadict.inc.php
lib/adodb/adodb-error.inc.php
lib/adodb/adodb-errorhandler.inc.php
lib/adodb/adodb-errorpear.inc.php
lib/adodb/adodb-exceptions.inc.php
lib/adodb/adodb-iterator.inc.php
lib/adodb/adodb-lib.inc.php
lib/adodb/adodb-memcache.lib.inc.php
lib/adodb/adodb-pager.inc.php
lib/adodb/adodb-pear.inc.php
lib/adodb/adodb-perf.inc.php
lib/adodb/adodb-php4.inc.php
lib/adodb/adodb-time.inc.php
lib/adodb/adodb.inc.php
lib/adodb/datadict/datadict-access.inc.php
lib/adodb/datadict/datadict-db2.inc.php
lib/adodb/datadict/datadict-firebird.inc.php
lib/adodb/datadict/datadict-generic.inc.php
lib/adodb/datadict/datadict-ibase.inc.php
lib/adodb/datadict/datadict-informix.inc.php
lib/adodb/datadict/datadict-mssql.inc.php
lib/adodb/datadict/datadict-mssqlnative.inc.php
lib/adodb/datadict/datadict-mysql.inc.php
lib/adodb/datadict/datadict-oci8.inc.php
lib/adodb/datadict/datadict-postgres.inc.php
lib/adodb/datadict/datadict-sapdb.inc.php
lib/adodb/datadict/datadict-sqlite.inc.php
lib/adodb/datadict/datadict-sybase.inc.php
lib/adodb/drivers/adodb-access.inc.php
lib/adodb/drivers/adodb-ado.inc.php
lib/adodb/drivers/adodb-ado5.inc.php
lib/adodb/drivers/adodb-ado_access.inc.php
lib/adodb/drivers/adodb-ado_mssql.inc.php
lib/adodb/drivers/adodb-borland_ibase.inc.php
lib/adodb/drivers/adodb-csv.inc.php
lib/adodb/drivers/adodb-db2.inc.php
lib/adodb/drivers/adodb-db2oci.inc.php
lib/adodb/drivers/adodb-db2ora.inc.php
lib/adodb/drivers/adodb-fbsql.inc.php
lib/adodb/drivers/adodb-firebird.inc.php
lib/adodb/drivers/adodb-ibase.inc.php
lib/adodb/drivers/adodb-informix.inc.php
lib/adodb/drivers/adodb-informix72.inc.php
lib/adodb/drivers/adodb-ldap.inc.php
lib/adodb/drivers/adodb-mssql.inc.php
lib/adodb/drivers/adodb-mssqlnative.inc.php
lib/adodb/drivers/adodb-mssqlpo.inc.php
lib/adodb/drivers/adodb-mysql.inc.php
lib/adodb/drivers/adodb-mysqli.inc.php
lib/adodb/drivers/adodb-mysqlpo.inc.php
lib/adodb/drivers/adodb-mysqlt.inc.php
lib/adodb/drivers/adodb-netezza.inc.php
lib/adodb/drivers/adodb-oci8.inc.php
lib/adodb/drivers/adodb-oci805.inc.php
lib/adodb/drivers/adodb-oci8po.inc.php
lib/adodb/drivers/adodb-oci8quercus.inc.php
lib/adodb/drivers/adodb-odbc.inc.php
lib/adodb/drivers/adodb-odbc_db2.inc.php
lib/adodb/drivers/adodb-odbc_mssql.inc.php
lib/adodb/drivers/adodb-odbc_oracle.inc.php
lib/adodb/drivers/adodb-odbtp.inc.php
lib/adodb/drivers/adodb-odbtp_unicode.inc.php
lib/adodb/drivers/adodb-oracle.inc.php
lib/adodb/drivers/adodb-pdo.inc.php
lib/adodb/drivers/adodb-pdo_mssql.inc.php
lib/adodb/drivers/adodb-pdo_mysql.inc.php
lib/adodb/drivers/adodb-pdo_oci.inc.php
lib/adodb/drivers/adodb-pdo_pgsql.inc.php
lib/adodb/drivers/adodb-pdo_sqlite.inc.php
lib/adodb/drivers/adodb-postgres.inc.php
lib/adodb/drivers/adodb-postgres64.inc.php
lib/adodb/drivers/adodb-postgres7.inc.php
lib/adodb/drivers/adodb-postgres8.inc.php
lib/adodb/drivers/adodb-postgres9.inc.php
lib/adodb/drivers/adodb-proxy.inc.php
lib/adodb/drivers/adodb-sapdb.inc.php
lib/adodb/drivers/adodb-sqlanywhere.inc.php
lib/adodb/drivers/adodb-sqlite.inc.php
lib/adodb/drivers/adodb-sqlite3.inc.php
lib/adodb/drivers/adodb-sqlitepo.inc.php
lib/adodb/drivers/adodb-sybase.inc.php
lib/adodb/drivers/adodb-sybase_ase.inc.php
lib/adodb/drivers/adodb-vfp.inc.php
lib/adodb/perf/perf-db2.inc.php
lib/adodb/perf/perf-informix.inc.php
lib/adodb/perf/perf-mssql.inc.php
lib/adodb/perf/perf-mssqlnative.inc.php
lib/adodb/perf/perf-mysql.inc.php
lib/adodb/perf/perf-oci8.inc.php
lib/adodb/perf/perf-postgres.inc.php
lib/adodb/pivottable.inc.php
lib/adodb/readme_moodle.txt
lib/adodb/rsfilter.inc.php
lib/adodb/toexport.inc.php
lib/adodb/tohtml.inc.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/src/form-autocomplete.js
lib/amd/src/modal.js
lib/antivirus/clamav/classes/scanner.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/antivirus/clamav/settings.php
lib/antivirus/clamav/tests/scanner_test.php
lib/antivirus/clamav/version.php
lib/badgeslib.php
lib/behat/classes/behat_context_helper.php
lib/classes/antivirus/scanner_exception.php
lib/classes/component.php
lib/classes/date.php
lib/classes/event/user_password_policy_failed.php [new file with mode: 0644]
lib/classes/lock/mysql_lock_factory.php [new file with mode: 0644]
lib/classes/message/manager.php
lib/classes/session/manager.php
lib/classes/task/database_logger.php
lib/classes/task/file_temp_cleanup_task.php
lib/db/access.php
lib/db/caches.php
lib/db/services.php
lib/db/upgrade.php
lib/ddl/database_manager.php
lib/ddl/mssql_sql_generator.php
lib/ddl/mysql_sql_generator.php
lib/ddl/oracle_sql_generator.php
lib/ddl/postgres_sql_generator.php
lib/ddl/sql_generator.php
lib/ddl/tests/ddl_test.php
lib/deprecatedlib.php
lib/dml/moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/oci_native_moodle_database.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/sqlite3_pdo_moodle_database.php
lib/dml/sqlsrv_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js
lib/editor/atto/plugins/h5p/yui/src/button/js/button.js
lib/filestorage/file_storage.php
lib/filterlib.php
lib/form/duration.php
lib/form/templates/element-checkbox.mustache
lib/form/tests/duration_test.php
lib/moodlelib.php
lib/outputlib.php
lib/phpspreadsheet/readme_moodle.txt
lib/phpspreadsheet/vendor/autoload.php
lib/phpspreadsheet/vendor/composer/ClassLoader.php
lib/phpspreadsheet/vendor/composer/LICENSE
lib/phpspreadsheet/vendor/composer/autoload_real.php
lib/phpspreadsheet/vendor/composer/autoload_static.php
lib/phpspreadsheet/vendor/composer/installed.json
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/Builder.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/Functions.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/Matrix.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/Operators/DirectSum.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/adjoint.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/antidiagonal.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/cofactors.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/determinant.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/diagonal.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/identity.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/inverse.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/minors.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/trace.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/functions/transpose.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/add.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/directsum.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/divideby.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/divideinto.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/multiply.php
lib/phpspreadsheet/vendor/markbaker/matrix/classes/src/operations/subtract.php
lib/phpspreadsheet/vendor/markbaker/matrix/composer.7.2.json [new file with mode: 0644]
lib/phpspreadsheet/vendor/markbaker/matrix/composer.json
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/CHANGELOG.md
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/LICENSE
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/composer.json
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/composer.lock
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/phpunit.xml.dist
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Calculation.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Database.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTime.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Functions.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Logical.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Token/Stack.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/functionlist.txt
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AdvancedValueBinder.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Cell.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Coordinate.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DefaultValueBinder.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Axis.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Chart.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataSeriesValues.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/GridLines.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Layout.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraph.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/CellsFactory.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Document/Properties.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/IOFactory.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Html.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/IReader.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods/Properties.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Slk.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/BaseParserClass.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/DataValidations.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/PageSetup.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Properties.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/SheetViewOptions.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/SheetViews.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Styles.php [new file with mode: 0644]
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/ReferenceHelper.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Settings.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/JAMA/Matrix.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/ExponentialBestFit.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LogarithmicBestFit.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PowerBestFit.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Spreadsheet.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Color.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Fill.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Style.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter/Column.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Column.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/ColumnDimension.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Dimension.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Drawing.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowCellIterator.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/SheetView.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Worksheet.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/BaseWriter.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Html.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/IWriter.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Content.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Chart.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Comments.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Workbook.php
lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php
lib/phpunit/classes/util.php
lib/questionlib.php
lib/templates/form_autocomplete_selection.mustache
lib/templates/form_autocomplete_selection_items.mustache [new file with mode: 0644]
lib/templates/local/toast/wrapper.mustache
lib/templates/modal.mustache
lib/tests/authlib_test.php
lib/tests/behat/behat_app.php
lib/tests/behat/behat_hooks.php
lib/tests/date_test.php
lib/tests/filterlib_test.php
lib/tests/fixtures/testable_core_h5p.php
lib/tests/h5p_get_content_types_task_test.php
lib/tests/lock_test.php
lib/tests/questionlib_test.php
lib/tests/theme_config_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker-debug.js
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker-min.js
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker.js
lib/yui/src/formchangechecker/js/formchangechecker.js
message/amd/build/message_drawer_view_conversation_constants.min.js
message/amd/build/message_drawer_view_conversation_constants.min.js.map
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js.map
message/amd/src/message_drawer_view_conversation_constants.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/classes/api.php
message/externallib.php
message/lib.php
message/output/popup/amd/build/notification_popover_controller.min.js
message/output/popup/amd/build/notification_popover_controller.min.js.map
message/output/popup/amd/src/notification_popover_controller.js
message/output/popup/lib.php
message/output/popup/version.php
message/templates/message_drawer_view_conversation_body_confirm_dialogue.mustache
message/templates/message_drawer_view_conversation_footer_content.mustache
message/templates/message_popover.mustache
message/tests/behat/message_drawer_manage_contacts.feature
message/tests/externallib_test.php
mod/assign/styles.css
mod/feedback/classes/responses_table.php
mod/forum/classes/local/factories/renderer.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_post_subject_with_context_links.mustache [new file with mode: 0644]
mod/forum/templates/forum_posts_with_context_links.mustache
mod/forum/templates/forum_search_results.mustache [new file with mode: 0644]
mod/forum/templates/social_discussion_list.mustache
mod/forum/tests/behat/edit_post_student.feature
mod/forum/view.php
mod/lti/lib.php
mod/lti/locallib.php
mod/lti/source/upgrade.txt
mod/lti/tests/lib_test.php
mod/quiz/attemptlib.php
mod/quiz/grade.php
mod/quiz/locallib.php
mod/quiz/mod_form.php
mod/quiz/renderer.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/overview/tests/helpers.php [new file with mode: 0644]
mod/quiz/report/overview/tests/report_test.php
mod/quiz/settings.php
mod/scorm/datamodels/aicc.js
mod/scorm/datamodels/scorm_12.js
mod/scorm/datamodels/scorm_13.js
mod/scorm/module.js
mod/scorm/request.js
mod/scorm/tests/behat/save_progress_on_unload.feature [new file with mode: 0644]
mod/upgrade.txt
pix/b/h5p_library.svg [new file with mode: 0644]
question/engine/lib.php
question/tests/behat/delete_question_activities.feature [new file with mode: 0644]
question/tests/behat/edit_questions.feature
question/type/questiontypebase.php
question/upgrade.txt
theme/boost/amd/build/form-display-errors.min.js
theme/boost/amd/build/form-display-errors.min.js.map
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/build/scroll.min.js [new file with mode: 0644]
theme/boost/amd/build/scroll.min.js.map [new file with mode: 0644]
theme/boost/amd/src/form-display-errors.js
theme/boost/amd/src/loader.js
theme/boost/amd/src/scroll.js [new file with mode: 0644]
theme/boost/lang/en/theme_boost.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/popover-region.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/templates/footer.mustache
theme/classic/style/moodle.css
version.php
webservice/wsdoc.php

index f45c261..bd39185 100644 (file)
@@ -167,24 +167,39 @@ module.exports = function(grunt) {
     // * fullRunDir             The full path to the runDir
     const gruntFilePath = fs.realpathSync(process.cwd());
     const cwd = getCwd(grunt);
-    const relativeCwd = cwd.replace(new RegExp(`${gruntFilePath}/?`), '');
+    const relativeCwd = path.relative(gruntFilePath, cwd);
     const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
     const inComponent = !!componentDirectory;
     const runDir = inComponent ? componentDirectory : relativeCwd;
     const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
-    grunt.log.debug(`The cwd was detected as ${cwd} with a fullRunDir of ${fullRunDir}`);
+    grunt.log.debug('============================================================================');
+    grunt.log.debug(`= Node version:        ${process.versions.node}`);
+    grunt.log.debug(`= grunt version:       ${grunt.package.version}`);
+    grunt.log.debug(`= process.cwd:         '` + process.cwd() + `'`);
+    grunt.log.debug(`= process.env.PWD:     '${process.env.PWD}'`);
+    grunt.log.debug(`= path.sep             '${path.sep}'`);
+    grunt.log.debug('============================================================================');
+    grunt.log.debug(`= gruntFilePath:       '${gruntFilePath}'`);
+    grunt.log.debug(`= relativeCwd:         '${relativeCwd}'`);
+    grunt.log.debug(`= componentDirectory:  '${componentDirectory}'`);
+    grunt.log.debug(`= inComponent:         '${inComponent}'`);
+    grunt.log.debug(`= runDir:              '${runDir}'`);
+    grunt.log.debug(`= fullRunDir:          '${fullRunDir}'`);
+    grunt.log.debug('============================================================================');
 
     if (inComponent) {
         grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
     }
 
-    var files = null;
+    let files = null;
     if (grunt.option('files')) {
         // Accept a comma separated list of files to process.
         files = grunt.option('files').split(',');
     }
 
-    const inAMD = path.basename(cwd) == 'amd';
+    // If the cwd is the amd directory in the current component then it will be empty.
+    // If the cwd is a child of the component's AMD directory, the relative directory will not start with ..
+    const inAMD = !path.relative(`${componentDirectory}/amd`, cwd).startsWith('..');
 
     // Globbing pattern for matching all AMD JS source files.
     let amdSrc = [];
@@ -235,7 +250,7 @@ module.exports = function(grunt) {
             const nodes = xpath.select("/libraries/library/location/text()", doc);
 
             nodes.forEach(function(node) {
-                let lib = path.join(dirname, node.toString());
+                let lib = path.posix.join(dirname, node.toString());
                 if (grunt.file.isDir(lib)) {
                     // Ensure trailing slash on dirs.
                     lib = lib.replace(/\/?$/, '/');
@@ -251,6 +266,24 @@ module.exports = function(grunt) {
         return libs;
     };
 
+    /**
+     * Get the list of feature files to pass to the gherkin linter.
+     *
+     * @returns {Array}
+     */
+    const getGherkinLintTargets = () => {
+        if (files) {
+            // Specific files were requested. Only check these.
+            return files;
+        }
+
+        if (inComponent) {
+            return [`${runDir}/tests/behat/*.feature`];
+        }
+
+        return ['**/tests/behat/*.feature'];
+    };
+
     // Project configuration.
     grunt.initConfig({
         eslint: {
@@ -366,7 +399,7 @@ module.exports = function(grunt) {
         },
         gherkinlint: {
             options: {
-                files: files ? files : ['**/tests/behat/*.feature'],
+                files: getGherkinLintTargets(),
             }
         },
     });
@@ -484,19 +517,31 @@ module.exports = function(grunt) {
     };
 
     tasks.gherkinlint = function() {
-        var done = this.async(),
-            options = grunt.config('gherkinlint.options');
-
-        var args = grunt.file.expand(options.files);
-        args.unshift(path.normalize(__dirname + '/node_modules/.bin/gherkin-lint'));
-        grunt.util.spawn({
-            cmd: 'node',
-            args: args,
-            opts: {stdio: 'inherit', env: process.env}
-        }, function(error, result, code) {
-            // Propagate the exit code.
-            done(code === 0);
-        });
+        const done = this.async();
+        const options = grunt.config('gherkinlint.options');
+
+        // Grab the gherkin-lint linter and required scaffolding.
+        const linter = require('gherkin-lint/src/linter.js');
+        const featureFinder = require('gherkin-lint/src/feature-finder.js');
+        const configParser = require('gherkin-lint/src/config-parser.js');
+        const formatter = require('gherkin-lint/src/formatters/stylish.js');
+
+        // Run the linter.
+        const results = linter.lint(
+            featureFinder.getFeatureFiles(grunt.file.expand(options.files)),
+            configParser.getConfiguration(configParser.defaultConfigFileName)
+        );
+
+        // Print the results out uncondtionally.
+        formatter.printResults(results);
+
+        // Report on the results.
+        // We exit 1 if there is at least one error, otherwise we exit cleanly.
+        if (results.some(result => result.errors.length > 0)) {
+            done(1);
+        } else {
+            done(0);
+        }
     };
 
     tasks.startup = function() {
index 06ed999..74bd9de 100644 (file)
@@ -131,9 +131,11 @@ const getYuiSrcGlobList = relativeTo => {
  */
 const getThirdPartyLibsList = relativeTo => {
     const fs = require('fs');
+    const path = require('path');
 
     return fetchComponentData().pathList
-        .map(componentPath => componentPath.replace(relativeTo, '') + '/thirdpartylibs.xml')
+        .map(componentPath => path.relative(relativeTo, componentPath) + '/thirdpartylibs.xml')
+        .map(componentPath => componentPath.replace(/\\/g, '/'))
         .filter(path => fs.existsSync(path))
         .sort();
 };
@@ -157,18 +159,19 @@ const getComponentFromPath = path => {
 /**
  * Check whether the supplied path, relative to the Gruntfile.js, is in a known component.
  *
- * @param {String} checkPath The path to check
+ * @param {String} checkPath The path to check. This can be with either Windows, or Linux directory separators.
  * @returns {String|null}
  */
 const getOwningComponentDirectory = checkPath => {
     const path = require('path');
 
-    const pathList = fetchComponentData().components;
-    for (const componentPath of Object.keys(pathList)) {
-        if (checkPath === componentPath) {
-            return componentPath;
-        }
-        if (checkPath.startsWith(componentPath + path.sep)) {
+    // Fetch all components into a reverse sorted array.
+    // This ensures that components which are within the directory of another component match first.
+    const pathList = Object.keys(fetchComponentData().components).sort().reverse();
+    for (const componentPath of pathList) {
+        // If the componentPath is the directory being checked, it will be empty.
+        // If the componentPath is a parent of the directory being checked, the relative directory will not start with ..
+        if (!path.relative(componentPath, checkPath).startsWith('..')) {
             return componentPath;
         }
     }
index ad5ede6..841b72b 100644 (file)
@@ -61,6 +61,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/restore:restorecourse')
         )
     );
+    $ADMIN->add('courses',
+        new admin_externalpage('activitychooser', new lang_string('activitychooserrecommendations', 'course'),
+            new moodle_url('/course/recommendations.php'),
+            array('moodle/course:recommendactivity')
+        )
+    );
 
     // Course Default Settings Page.
     // NOTE: these settings must be applied after all other settings because they depend on them.
index 81ac282..881b34c 100644 (file)
@@ -25,5 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 // Settings page.
+$ADMIN->add('h5p', new admin_externalpage('h5poverview', get_string('h5poverview', 'core_h5p'),
+    new moodle_url('/h5p/overview.php'), ['moodle/site:config']));
 $ADMIN->add('h5p', new admin_externalpage('h5psettings', get_string('h5pmanage', 'core_h5p'),
     new moodle_url('/h5p/libraries.php'), ['moodle/site:config', 'moodle/h5p:updatelibraries']));
index 1b91d6a..d8dc48a 100644 (file)
@@ -100,6 +100,9 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configtext('minpasswordupper', new lang_string('minpasswordupper', 'admin'), new lang_string('configminpasswordupper', 'admin'), 1, PARAM_INT));
     $temp->add(new admin_setting_configtext('minpasswordnonalphanum', new lang_string('minpasswordnonalphanum', 'admin'), new lang_string('configminpasswordnonalphanum', 'admin'), 1, PARAM_INT));
     $temp->add(new admin_setting_configtext('maxconsecutiveidentchars', new lang_string('maxconsecutiveidentchars', 'admin'), new lang_string('configmaxconsecutiveidentchars', 'admin'), 0, PARAM_INT));
+    $temp->add(new admin_setting_configcheckbox('passwordpolicycheckonlogin',
+        new lang_string('passwordpolicycheckonlogin', 'admin'),
+        new lang_string('configpasswordpolicycheckonlogin', 'admin'), 0));
 
     $temp->add(new admin_setting_configtext('passwordreuselimit',
         new lang_string('passwordreuselimit', 'admin'),
index f3b1ac0..46d5706 100644 (file)
@@ -32,7 +32,7 @@ require_once($CFG->libdir . '/behat/classes/behat_config_manager.php');
 // systems, but let's allow room for expansion.
 core_php_time_limit::raise(300);
 
-$filter = optional_param('filter', '', PARAM_ALPHANUMEXT);
+$filter = optional_param('filter', '', PARAM_NOTAGS);
 $type = optional_param('type', false, PARAM_ALPHAEXT);
 $component = optional_param('component', '', PARAM_ALPHAEXT);
 
index 83c3a8d..f4e6537 100644 (file)
@@ -55,13 +55,13 @@ class tool_behat {
         // The loaded steps depends on the component specified.
         behat_config_manager::update_config_file($component, false);
 
-        // The Moodle\BehatExtension\HelpPrinter\MoodleDefinitionsPrinter will parse this search format.
+        // The Moodle\BehatExtension\Definition\Printer\ConsoleDefinitionInformationPrinter will parse this search format.
         if ($type) {
             $filter .= '&&' . $type;
         }
 
         if ($filter) {
-            $filteroption = ' -d "' . $filter . '"';
+            $filteroption = ' -d ' . escapeshellarg($filter);
         } else {
             $filteroption = ' -di';
         }
index 7de834a..864dc53 100644 (file)
@@ -44,13 +44,9 @@ class tool_behat_renderer extends plugin_renderer_base {
         global $CFG;
         require_once($CFG->libdir . '/behat/classes/behat_selectors.php');
 
-        $html = $this->generic_info();
-
-        // Form.
-        ob_start();
-        $form->display();
-        $html .= ob_get_contents();
-        ob_end_clean();
+        $html = $this->output->header();
+        $html .= $this->output->heading(get_string('pluginname', 'tool_behat'));
+        $html .= $form->render();
 
         if (empty($stepsdefinitions)) {
             $stepsdefinitions = get_string('nostepsdefinitions', 'tool_behat');
@@ -128,7 +124,9 @@ class tool_behat_renderer extends plugin_renderer_base {
      */
     public function render_error($msg) {
 
-        $html = $this->generic_info();
+        $html = $this->output->header();
+        $html .= $this->output->heading(get_string('pluginname', 'tool_behat'));
+        $html .= $this->generic_info();
 
         $a = new stdClass();
         $a->errormsg = $msg;
@@ -153,13 +151,7 @@ class tool_behat_renderer extends plugin_renderer_base {
      *
      * @return string
      */
-    protected function generic_info() {
-
-        $title = get_string('pluginname', 'tool_behat');
-
-        // Header.
-        $html = $this->output->header();
-        $html .= $this->output->heading($title);
+    public function generic_info() {
 
         // Info.
         $installurl = behat_command::DOCS_URL;
@@ -175,8 +167,7 @@ class tool_behat_renderer extends plugin_renderer_base {
         );
 
         // List of steps.
-        $html .= $this->output->box_start();
-        $html .= html_writer::tag('h3', get_string('infoheading', 'tool_behat'));
+        $html = $this->output->box_start();
         $html .= html_writer::tag('div', get_string('aim', 'tool_behat'));
         $html .= html_writer::start_tag('div');
         $html .= html_writer::start_tag('ul');
index 12bbee6..f9bef39 100644 (file)
@@ -40,8 +40,14 @@ class steps_definitions_form extends moodleform {
      * @return void
      */
     public function definition() {
+        global $PAGE;
 
         $mform = $this->_form;
+        $output = $PAGE->get_renderer('tool_behat');
+
+        $mform->addElement('header', 'info', get_string('infoheading', 'tool_behat'));
+        $mform->setExpanded('info', false);
+        $mform->addElement('html', $output->generic_info());
 
         $mform->addElement('header', 'filters', get_string('stepsdefinitionsfilters', 'tool_behat'));
 
index 8c5634a..c4fdb63 100644 (file)
@@ -1,25 +1,31 @@
-.steps-definitions {
-    border-style: solid;
-    border-width: 1px;
-    border-color: #bbb;
-    padding: 5px;
-    margin: auto;
-    width: 50%;
+#page-admin-tool-behat-index .steps-definitions {
+    margin: 1rem auto;
 }
 
-.steps-definitions .step {
-    margin: 10px 0 10px 0;
+#page-admin-tool-behat-index .steps-definitions .step {
+    margin: 1rem 0 0 0;
+    border: 1px solid #eee;
+    padding: 1rem;
 }
 
-.steps-definitions .stepdescription {
-    color: #bf8c12;
+#page-admin-tool-behat-index .steps-definitions .stepdescription {
+    font-style: italic;
 }
 
-.steps-definitions .steptype {
+#page-admin-tool-behat-index .steps-definitions .stepcontent {
+    margin: 1rem 0;
+}
+
+#page-admin-tool-behat-index .steps-definitions .steptype {
     color: #1467a6;
-    margin-right: 5px;
+    margin-right: 1ex;
+}
+
+#page-admin-tool-behat-index .steps-definitions .stepapipath {
+    font-family: monospace;
+    font-size: smaller;
 }
 
-.steps-definitions .stepregex {
+#page-admin-tool-behat-index .steps-definitions .stepregex {
     color: #060;
 }
index 9ad81f9..8e59cb8 100644 (file)
@@ -26,3 +26,11 @@ Feature: List the system steps definitions
     Given I set the field "Contains" to "homepage"
     When I press "Filter"
     Then I should see "Opens Moodle homepage."
+
+  @javascript
+  Scenario: Filtering by the multiple words pattern
+    Given I set the field "Contains" to "should exist"
+    When I press "Filter"
+    Then I should not see "There aren't steps definitions matching this filter"
+    And I should see "Checks the provided element and selector type exists in the current page."
+    And I should see "Checks that an element and selector type exists in another element and selector type on the current page."
index d083f28..64f8762 100644 (file)
@@ -76,6 +76,7 @@ class tool_capability_renderer extends plugin_renderer_base {
      * @return string
      */
     public function capability_comparison_table(array $capabilities, $contextid, array $roles, $onlydiff=false) {
+        static $capabilitycontexts = array();
 
         $strpermissions = $this->get_permission_strings();
         $permissionclasses = $this->get_permission_classes();
@@ -94,7 +95,11 @@ class tool_capability_renderer extends plugin_renderer_base {
         $table->data = array();
 
         foreach ($capabilities as $capability) {
-            $contexts = tool_capability_calculate_role_data($capability, $roles);
+            if (empty($capabilitycontexts[$capability])) {
+                $capabilitycontexts[$capability] = tool_capability_calculate_role_data($capability, $roles);
+            }
+            $contexts = $capabilitycontexts[$capability];
+
             $captitle = new html_table_cell(get_capability_string($capability) . html_writer::span($capability));
             $captitle->header = true;
 
index 4126d26..75de14b 100644 (file)
@@ -433,6 +433,7 @@ class api {
                 '$mmLoginEmailSignup' => new lang_string('startsignup'),
                 'NoDelegate_ForgottenPassword' => new lang_string('forgotten'),
                 'NoDelegate_ResponsiveMainMenuItems' => new lang_string('responsivemainmenuitems', 'tool_mobile'),
+                'NoDelegate_H5POffline' => new lang_string('h5poffline', 'tool_mobile'),
                 'NoDelegate_DarkMode' => new lang_string('darkmode', 'tool_mobile'),
             ),
             "$mainmenu" => array(
index 722bd3d..ff77a57 100644 (file)
@@ -63,6 +63,7 @@ $string['forcedurlscheme'] = 'If you want to allow only your custom branded app
 $string['forcedurlscheme_key'] = 'URL scheme';
 $string['forcelogout'] = 'Force log out';
 $string['forcelogout_desc'] = 'If enabled, the app option \'Change site\' is replaced by \'Log out\'. This results in the user being completely logged out. They must then re-enter their password the next time they wish to access the site.';
+$string['h5poffline'] = 'View H5P content offline';
 $string['httpsrequired'] = 'HTTPS required';
 $string['insecurealgorithmwarning'] = 'It seems that the HTTPS certificate uses an insecure algorithm for signing (SHA-1). Please try updating the certificate.';
 $string['invalidcertificatechainwarning'] = 'It seems that the certificate chain is invalid.';
index efefd32..e42049b 100644 (file)
@@ -27,6 +27,4 @@ defined('MOODLE_INTERNAL') || die;
 
 if ($hassiteconfig) {
     $ADMIN->add('development', new admin_externalpage('toolphpunit', get_string('pluginname', 'tool_phpunit'), "$CFG->wwwroot/$CFG->admin/tool/phpunit/index.php"));
-    $ADMIN->add('development', new admin_externalpage('toolphpunitwebrunner', get_string('pluginname', 'tool_phpunit'), "$CFG->wwwroot/$CFG->admin/tool/phpunit/webrunner.php",
-        'moodle/site:config', true));
 }
diff --git a/admin/tool/phpunit/upgrade.txt b/admin/tool/phpunit/upgrade.txt
new file mode 100644 (file)
index 0000000..4580c98
--- /dev/null
@@ -0,0 +1,4 @@
+This files describes API changes in the tool_phpunit code.
+
+=== 3.9 ===
+* webrunner was removed
diff --git a/admin/tool/phpunit/webrunner.php b/admin/tool/phpunit/webrunner.php
deleted file mode 100644 (file)
index 47a2b42..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * PHPUnit shell execution wrapper
- *
- * @package    tool_phpunit
- * @copyright  2012 Petr Skoda {@link http://skodak.org}
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-define('NO_OUTPUT_BUFFERING', true);
-
-require(__DIR__ . '/../../../config.php');
-require_once($CFG->libdir.'/adminlib.php');
-
-$testpath  = optional_param('testpath', '', PARAM_PATH);
-$testclass = optional_param('testclass', '', PARAM_ALPHANUMEXT);
-$execute   = optional_param('execute', 0, PARAM_BOOL);
-
-navigation_node::override_active_url(new moodle_url('/admin/tool/phpunit/index.php'));
-admin_externalpage_setup('toolphpunitwebrunner');
-
-if (!$CFG->debugdeveloper) {
-    print_error('notlocalisederrormessage', 'error', '', null, 'Not available on production sites, sorry.');
-}
-
-core_php_time_limit::raise(60*30);
-
-$oldcwd = getcwd();
-$code = 0;
-
-if (!isset($CFG->phpunit_dataroot) or !isset($CFG->phpunit_prefix)) {
-    tool_phpunit_problem('Missing $CFG->phpunit_dataroot or $CFG->phpunit_prefix, can not execute tests.');
-}
-if (!file_exists($CFG->phpunit_dataroot)) {
-    mkdir($CFG->phpunit_dataroot, 02777, true);
-}
-if (!is_writable($CFG->phpunit_dataroot)) {
-    tool_phpunit_problem('$CFG->phpunit_dataroot in not writable, can not execute tests.');
-}
-$output = null;
-exec('php --version', $output, $code);
-if ($code != 0) {
-    tool_phpunit_problem('Can not execute \'php\' binary.');
-}
-
-if ($execute) {
-    require_sesskey();
-
-    chdir($CFG->dirroot);
-    $output = null;
-    exec("php $CFG->admin/tool/phpunit/cli/util.php --diag", $output, $code);
-    if ($code == 0) {
-        // everything is ready
-
-    } else if ($code == PHPUNIT_EXITCODE_INSTALL) {
-        tool_phpunit_header();
-        echo $OUTPUT->box_start('generalbox');
-        echo '<pre>';
-        echo "Initialising test database:\n\n";
-        chdir($CFG->dirroot);
-        ignore_user_abort(true);
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --buildconfig", $code);
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --install", $code);
-        chdir($oldcwd);
-        echo '</pre>';
-        echo $OUTPUT->box_end();
-        if ($code != 0) {
-            tool_phpunit_problem('Can not initialize database');
-        }
-        set_debugging(DEBUG_NONE, false); // Hack: no redirect warning, we really want to redirect.
-        redirect(new moodle_url($PAGE->url, array('execute'=>1, 'tespath'=>$testpath, 'testclass'=>$testclass, 'sesskey'=>sesskey())), 'Reloading page');
-        echo $OUTPUT->footer();
-        die();
-
-    } else if ($code == PHPUNIT_EXITCODE_REINSTALL) {
-        tool_phpunit_header();
-        echo $OUTPUT->box_start('generalbox');
-        echo '<pre>';
-        echo "Reinitialising test database:\n\n";
-        chdir($CFG->dirroot);
-        ignore_user_abort(true);
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --drop", $code);
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --buildconfig", $code);
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --install", $code);
-        chdir($oldcwd);
-        echo '</pre>';
-        echo $OUTPUT->box_end();
-        if ($code != 0) {
-            tool_phpunit_problem('Can not initialize database');
-        }
-        set_debugging(DEBUG_NONE, false); // Hack: no redirect warning, we really want to redirect.
-        redirect(new moodle_url($PAGE->url, array('execute'=>1, 'tespath'=>$testpath, 'testclass'=>$testclass, 'sesskey'=>sesskey())), 'Reloading page');
-        die();
-
-    } else {
-        tool_phpunit_header();
-        echo $OUTPUT->box_start('generalbox');
-        echo '<pre>';
-        echo "Error: $code\n\n";
-        echo implode("\n", $output);
-        echo '</pre>';
-        echo $OUTPUT->box_end();
-        tool_phpunit_problem('Can not execute tests');
-        die();
-    }
-
-    tool_phpunit_header();
-    echo $OUTPUT->box_start('generalbox');
-    echo '<pre>';
-
-    // use the dataroot file
-    $configdir = "$CFG->phpunit_dataroot/phpunit/webrunner.xml";
-    if (!file_exists($configdir)) {
-        passthru("php $CFG->admin/tool/phpunit/cli/util.php --buildconfig", $code);
-        if ($code != 0) {
-            tool_phpunit_problem('Can not create configuration file');
-        }
-    }
-    $configdir = escapeshellarg($configdir);
-    // no cleanup of path - this is tricky because we can not use escapeshellarg and friends for escaping,
-    // this is from admin user so PARAM_PATH must be enough
-    chdir($CFG->dirroot);
-    passthru("php $CFG->admin/tool/phpunit/cli/util.php --run -c $configdir $testclass $testpath", $code);
-    chdir($oldcwd);
-
-    echo '</pre>';
-    echo $OUTPUT->box_end();
-
-} else {
-    tool_phpunit_header();
-}
-
-echo $OUTPUT->box_start('generalbox boxwidthwide boxaligncenter');
-echo '<form method="get" action="webrunner.php">';
-echo '<fieldset class="invisiblefieldset">';
-echo '<label for="testpath">Test one file</label> ';
-echo '<input type="text" id="testpath" name="testpath" value="'.s($testpath).'" size="50" /> (all test cases from webrunner.xml if empty)';
-echo '</p>';
-echo '<label for="testclass">Class name</label> ';
-echo '<input type="text" id="testclass" name="testclass" value="'.s($testclass).'" size="50" /> (first class in file if empty)';
-echo '</p>';
-echo '<input type="submit" value="Run" />';
-echo '<input type="hidden" name="execute" value="1" />';
-echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
-echo '</fieldset>';
-echo '</form>';
-echo $OUTPUT->box_end();
-echo $OUTPUT->footer();
-die;
-
-
-
-//========================================
-
-/**
- * Print headers and experimental warning
- * @return void
- */
-function tool_phpunit_header() {
-    global $OUTPUT;
-    echo $OUTPUT->header();
-    echo $OUTPUT->heading(get_string('pluginname', 'tool_phpunit'));
-    echo $OUTPUT->box('EXPERIMENTAL: it is recommended to execute PHPUnit tests and init scripts only from command line.', array('generalbox'));
-}
-
-/**
- * Called when PHPUnit can not execute.
- * @param string $message
- * @return void
- */
-function tool_phpunit_problem($message) {
-    global $PAGE;
-    if (!$PAGE->headerprinted) {
-        tool_phpunit_header();
-    }
-    notice($message, new moodle_url('/admin/tool/phpunit/'));
-}
index 751a75b..90aeb02 100644 (file)
@@ -1,4 +1,26 @@
 <?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Bulk user actions
+ *
+ * @package    core
+ * @copyright  Moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 
 require_once('../../config.php');
 require_once($CFG->libdir.'/adminlib.php');
@@ -10,28 +32,23 @@ admin_externalpage_setup('userbulk');
 if (!isset($SESSION->bulk_users)) {
     $SESSION->bulk_users = array();
 }
-// create the user filter form
+// Create the user filter form.
 $ufiltering = new user_filtering();
 
-// array of bulk operations
-// create the bulk operations form
-$action_form = new user_bulk_action_form();
-if ($data = $action_form->get_data()) {
-    // check if an action should be performed and do so
-    switch ($data->action) {
-        case 1: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_confirm.php');
-        case 2: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_message.php');
-        case 3: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_delete.php');
-        case 4: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_display.php');
-        case 5: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_download.php');
-        case 7: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_forcepasswordchange.php');
-        case 8: redirect($CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk_cohortadd.php');
+// Create the bulk operations form.
+$actionform = new user_bulk_action_form();
+if ($data = $actionform->get_data()) {
+    // Check if an action should be performed and do so.
+    $bulkactions = $actionform->get_actions();
+    if (array_key_exists($data->action, $bulkactions)) {
+        redirect($bulkactions[$data->action]->url);
     }
+
 }
 
-$user_bulk_form = new user_bulk_form(null, get_selection_data($ufiltering));
+$userbulkform = new user_bulk_form(null, get_selection_data($ufiltering));
 
-if ($data = $user_bulk_form->get_data()) {
+if ($data = $userbulkform->get_data()) {
     if (!empty($data->addall)) {
         add_selection_all($ufiltering);
 
@@ -40,7 +57,7 @@ if ($data = $user_bulk_form->get_data()) {
             if (in_array(0, $data->ausers)) {
                 add_selection_all($ufiltering);
             } else {
-                foreach($data->ausers as $userid) {
+                foreach ($data->ausers as $userid) {
                     if ($userid == -1) {
                         continue;
                     }
@@ -52,14 +69,14 @@ if ($data = $user_bulk_form->get_data()) {
         }
 
     } else if (!empty($data->removeall)) {
-        $SESSION->bulk_users= array();
+        $SESSION->bulk_users = array();
 
     } else if (!empty($data->removesel)) {
         if (!empty($data->susers)) {
             if (in_array(0, $data->susers)) {
-                $SESSION->bulk_users= array();
+                $SESSION->bulk_users = array();
             } else {
-                foreach($data->susers as $userid) {
+                foreach ($data->susers as $userid) {
                     if ($userid == -1) {
                         continue;
                     }
@@ -69,18 +86,17 @@ if ($data = $user_bulk_form->get_data()) {
         }
     }
 
-    // reset the form selections
+    // Reset the form selections.
     unset($_POST);
-    $user_bulk_form = new user_bulk_form(null, get_selection_data($ufiltering));
+    $userbulkform = new user_bulk_form(null, get_selection_data($ufiltering));
 }
-// do output
 echo $OUTPUT->header();
 
 $ufiltering->display_add();
 $ufiltering->display_active();
 
-$user_bulk_form->display();
+$userbulkform->display();
 
-$action_form->display();
+$actionform->display();
 
 echo $OUTPUT->footer();
index ee71a22..2dcf4c5 100644 (file)
 <?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Bulk user action forms
+ *
+ * @package    core
+ * @copyright  Moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->libdir.'/datalib.php');
 
+/**
+ * Bulk user action form
+ *
+ * @copyright  Moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class user_bulk_action_form extends moodleform {
-    function definition() {
-        global $CFG;
 
-        $mform =& $this->_form;
+    /**
+     * Returns an array of action_link's of all bulk actions available for this user.
+     *
+     * @return array of action_link objects
+     */
+    public function get_actions(): array {
+
+        global $CFG;
 
         $syscontext = context_system::instance();
-        $actions = array(0=>get_string('choose').'...');
+        $actions = [];
         if (has_capability('moodle/user:update', $syscontext)) {
-            $actions[1] = get_string('confirm');
+            $actions['confirm'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_confirm.php'),
+                get_string('confirm'));
         }
         if (has_capability('moodle/site:readallmessages', $syscontext) && !empty($CFG->messaging)) {
-            $actions[2] = get_string('messageselectadd');
+            $actions['message'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_message.php'),
+                get_string('messageselectadd'));
         }
         if (has_capability('moodle/user:delete', $syscontext)) {
-            $actions[3] = get_string('delete');
+            $actions['delete'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_delete.php'),
+                get_string('delete'));
         }
-        $actions[4] = get_string('displayonpage');
+        $actions['displayonpage'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_display.php'),
+                get_string('displayonpage'));
+
         if (has_capability('moodle/user:update', $syscontext)) {
-            $actions[5] = get_string('download', 'admin');
+            $actions['download'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_download.php'),
+                get_string('download', 'admin'));
         }
+
         if (has_capability('moodle/user:update', $syscontext)) {
-            $actions[7] = get_string('forcepasswordchange');
+            $actions['forcepasswordchange'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_forcepasswordchange.php'),
+                get_string('forcepasswordchange'));
         }
         if (has_capability('moodle/cohort:assign', $syscontext)) {
-            $actions[8] = get_string('bulkadd', 'core_cohort');
+            $actions['addtocohort'] = new action_link(
+                new moodle_url('/admin/user/user_bulk_cohortadd.php'),
+                get_string('bulkadd', 'core_cohort'));
+        }
+
+        // Any plugin can append actions to this list by implementing a callback
+        // <component>_bulk_user_actions() which returns an array of action_link.
+        // Each new action's key should have a frankenstyle prefix to avoid clashes.
+        // See MDL-38511 for more details.
+        $moreactions = get_plugins_with_function('bulk_user_actions', 'lib.php');
+        foreach ($moreactions as $plugintype => $plugins) {
+            foreach ($plugins as $pluginfunction) {
+                $actions += $pluginfunction();
+            }
+        }
+
+        return $actions;
+
+    }
+
+    /**
+     * Form definition
+     */
+    public function definition() {
+        global $CFG;
+
+        $mform =& $this->_form;
+
+        $actions = [0 => get_string('choose') . '...'];
+        $bulkactions = $this->get_actions();
+        foreach ($bulkactions as $key => $action) {
+            $actions[$key] = $action->text;
         }
         $objs = array();
         $objs[] =& $mform->createElement('select', 'action', null, $actions);
@@ -37,8 +117,18 @@ class user_bulk_action_form extends moodleform {
     }
 }
 
+/**
+ * Bulk user form
+ *
+ * @copyright  Moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class user_bulk_form extends moodleform {
-    function definition() {
+
+    /**
+     * Form definition
+     */
+    public function definition() {
 
         $mform =& $this->_form;
         $acount =& $this->_customdata['acount'];
@@ -92,7 +182,6 @@ class user_bulk_form extends moodleform {
         $objs[1] =& $mform->createElement('select', 'susers', get_string('selected', 'bulkusers'), $schoices, 'size="15"');
         $objs[1]->setMultiple(true);
 
-
         $grp =& $mform->addElement('group', 'usersgrp', get_string('users', 'bulkusers'), $objs, ' ', false);
         $mform->addHelpButton('usersgrp', 'users', 'bulkusers');
 
index ad0c55f..8bedda9 100644 (file)
@@ -263,7 +263,8 @@ class auth_plugin_shibboleth extends auth_plugin_base {
         global $OUTPUT;
 
         if (!isset($this->config->user_attribute) || empty($this->config->user_attribute)) {
-            echo $OUTPUT->notification(get_string("shib_not_set_up_error", "auth_shibboleth"), 'notifyproblem');
+            echo $OUTPUT->notification(get_string("shib_not_set_up_error", "auth_shibboleth",
+                (new moodle_url('/auth/shibboleth/README.txt'))->out()), 'notifyproblem');
             return;
         }
         if ($this->config->convert_data and $this->config->convert_data != '' and !is_readable($this->config->convert_data)) {
index b177f7c..0e0752c 100644 (file)
@@ -32,8 +32,9 @@
     $shibbolethauth = get_auth_plugin('shibboleth');
 
     // Check whether Shibboleth is configured properly
+    $readmeurl = (new moodle_url('/auth/shibboleth/README.txt'))->out();
     if (empty($pluginconfig->user_attribute)) {
-        print_error('shib_not_set_up_error', 'auth_shibboleth');
+        print_error('shib_not_set_up_error', 'auth_shibboleth', '', $readmeurl);
      }
 
 /// If we can find the Shibboleth attribute, save it in session and return to main login page
@@ -91,7 +92,7 @@
     elseif (!empty($_SERVER['HTTP_SHIB_APPLICATION_ID']) || !empty($_SERVER['Shib-Application-ID'])) {
         print_error('shib_no_attributes_error', 'auth_shibboleth' , '', '\''.$pluginconfig->user_attribute.'\', \''.$pluginconfig->field_map_firstname.'\', \''.$pluginconfig->field_map_lastname.'\' and \''.$pluginconfig->field_map_email.'\'');
     } else {
-        print_error('shib_not_set_up_error', 'auth_shibboleth');
+        print_error('shib_not_set_up_error', 'auth_shibboleth', '', $readmeurl);
     }
 
 
index bff0267..ca9b65b 100644 (file)
@@ -28,7 +28,7 @@ $string['auth_shib_auth_method_description'] = 'Provide a name for the Shibbolet
 $string['auth_shib_auth_logo'] = 'Authentication method logo';
 $string['auth_shib_auth_logo_description'] = 'Provide a logo for the Shibboleth authentication method that is familiar to your users. This could be the logo of your Shibboleth federation, e.g. <tt>SWITCHaai Login</tt> or <tt>InCommon Login</tt> or similar.';
 $string['auth_shib_contact_administrator'] = 'In case you are not associated with the given organizations and you need access to a course on this server, please contact the <a href="mailto:{$a}">Moodle Administrator</a>.';
-$string['auth_shibbolethdescription'] = 'Using this method users are created and authenticated using Shibboleth. For set-up details, see the <a href="../auth/shibboleth/README.txt">Shibboleth README</a>.';
+$string['auth_shibbolethdescription'] = 'Using this method users are created and authenticated using Shibboleth. For set-up details, see the <a href="{$a}">Shibboleth README</a>.';
 $string['auth_shibboleth_errormsg'] = 'Please select the organization you are member of!';
 $string['auth_shibboleth_login'] = 'Shibboleth login';
 $string['auth_shibboleth_login_long'] = 'Login to Moodle via Shibboleth';
@@ -36,7 +36,7 @@ $string['auth_shibboleth_manual_login'] = 'Manual login';
 $string['auth_shibboleth_select_member'] = 'I\'m a member of ...';
 $string['auth_shibboleth_select_organization'] = 'For authentication via Shibboleth, please select your organisation from the drop-down menu:';
 $string['auth_shib_convert_data'] = 'Data modification API';
-$string['auth_shib_convert_data_description'] = 'You can use this API to further modify the data provided by Shibboleth. Read the <a href="../auth/shibboleth/README.txt">README</a> for further instructions.';
+$string['auth_shib_convert_data_description'] = 'You can use this API to further modify the data provided by Shibboleth. Read the <a href="{$a}">README</a> for further instructions.';
 $string['auth_shib_convert_data_warning'] = 'The file does not exist or is not readable by the webserver process!';
 $string['auth_shib_changepasswordurl'] = 'Password-change URL';
 $string['auth_shib_idp_list'] = 'Identity providers';
@@ -57,6 +57,6 @@ $string['auth_shib_username_description'] = 'Name of the webserver Shibboleth en
 $string['shib_invalid_account_error'] = 'You seem to be Shibboleth authenticated but Moodle has no valid account for your username. Your account may not exist or it may have been suspended.';
 $string['shib_no_attributes_error'] = 'You seem to be Shibboleth authenticated but Moodle didn\'t receive any user attributes. Please check that your Identity Provider releases the necessary attributes ({$a}) to the Service Provider Moodle is running on or inform the webmaster of this server.';
 $string['shib_not_all_attributes_error'] = 'Moodle needs certain Shibboleth attributes which are not present in your case. The attributes are: {$a}<br />Please contact the webmaster of this server or your Identity Provider.';
-$string['shib_not_set_up_error'] = 'Shibboleth authentication doesn\'t seem to be set up correctly because no Shibboleth environment variables are present for this page. Please consult the <a href="README.txt">README</a> for further instructions on how to set up Shibboleth authentication or contact the webmaster of this Moodle installation.';
+$string['shib_not_set_up_error'] = 'Shibboleth authentication doesn\'t seem to be set up correctly because no Shibboleth environment variables are present for this page. Please consult the <a href="{$a}">README</a> for further instructions on how to set up Shibboleth authentication or contact the webmaster of this Moodle installation.';
 $string['pluginname'] = 'Shibboleth';
 $string['privacy:metadata'] = 'The Shibboleth authentication plugin does not store any personal data.';
index e4b4c3a..86dce35 100644 (file)
@@ -30,8 +30,9 @@ if ($ADMIN->fulltree) {
     require_once($CFG->dirroot.'/auth/shibboleth/classes/admin_setting_special_idp_configtextarea.php');
 
     // Introductory explanation.
+    $readmeurl = (new moodle_url('/auth/shibboleth/README.txt'))->out();
     $settings->add(new admin_setting_heading('auth_shibboleth/pluginname', '',
-            new lang_string('auth_shibbolethdescription', 'auth_shibboleth')));
+            new lang_string('auth_shibbolethdescription', 'auth_shibboleth', $readmeurl)));
 
     // Username.
     $settings->add(new admin_setting_configtext('auth_shibboleth/user_attribute', get_string('username'),
@@ -40,7 +41,7 @@ if ($ADMIN->fulltree) {
     // COnvert Data configuration file.
     $settings->add(new admin_setting_configfile('auth_shibboleth/convert_data',
             get_string('auth_shib_convert_data', 'auth_shibboleth'),
-            get_string('auth_shib_convert_data_description', 'auth_shibboleth'), ''));
+            get_string('auth_shib_convert_data_description', 'auth_shibboleth', $readmeurl), ''));
 
     // WAYF.
     $settings->add(new auth_shibboleth_admin_setting_special_wayf_select());
index f6d5e09..faa64a6 100644 (file)
@@ -49,7 +49,6 @@ class badge extends moodleform {
         $mform = $this->_form;
         $badge = (isset($this->_customdata['badge'])) ? $this->_customdata['badge'] : false;
         $action = $this->_customdata['action'];
-        $languages = get_string_manager()->get_list_of_languages();
 
         $mform->addElement('header', 'badgedetails', get_string('badgedetails', 'badges'));
         $mform->addElement('text', 'name', get_string('name'), array('size' => '70'));
@@ -61,6 +60,8 @@ class badge extends moodleform {
         $mform->addElement('text', 'version', get_string('version', 'badges'), array('size' => '70'));
         $mform->setType('version', PARAM_TEXT);
         $mform->addHelpButton('version', 'version', 'badges');
+
+        $languages = get_string_manager()->get_list_of_languages();
         $mform->addElement('select', 'language', get_string('language'), $languages);
         $mform->addHelpButton('language', 'language', 'badges');
 
@@ -157,7 +158,16 @@ class badge extends moodleform {
         $mform->setType('action', PARAM_TEXT);
 
         if ($action == 'new') {
-            $mform->setDefault('language', $CFG->lang);
+            // Try to set default badge language to that of current language, or it's parent.
+            $language = current_language();
+            if (isset($languages[$language])) {
+                $defaultlanguage = $language;
+            } else {
+                // Calling get_parent_language returns an empty string instead of 'en'.
+                $defaultlanguage = get_parent_language($language) ?: 'en';
+            }
+
+            $mform->setDefault('language', $defaultlanguage);
             $this->add_action_buttons(true, get_string('createbutton', 'badges'));
         } else {
             // Add hidden fields.
index 9507bfa..23fbcc8 100644 (file)
@@ -114,23 +114,59 @@ class PNG_MetaDataHandler
      * @param string $value Currently an assertion URL that is added to an image metadata.
      *
      * @return string $result File content with a new chunk as a string. Can be used in file_put_contents() to write to a file.
+     * @throws \moodle_exception when unsupported chunk type is defined.
      */
     public function add_chunks($type, $key, $value) {
         if (strlen($key) > 79) {
             debugging('Key is too big');
         }
 
-        // tEXt Textual data.
-        // Keyword:        1-79 bytes (character string)
-        // Null separator: 1 byte
-        // Text:           n bytes (character string)
-        $data = $key . "\0" . $value;
+        $dataparts = [];
+        if ($type === 'iTXt') {
+            // International textual data (iTXt).
+            // Keyword:             1-79 bytes (character string).
+            $dataparts[] = $key;
+            // Null separator:      1 byte.
+            $dataparts[] = "\x00";
+            // Compression flag:    1 byte
+            // A value of 0 means no compression.
+            $dataparts[] = "\x00";
+            // Compression method:  1 byte
+            // If compression is disabled, the method should also be 0.
+            $dataparts[] = "\x00";
+            // Language tag:        0 or more bytes (character string)
+            // When there is no language specified leave empty.
+
+            // Null separator:      1 byte.
+            $dataparts[] = "\x00";
+            // Translated keyword:  0 or more bytes
+            // When there is no translation specified, leave empty.
+
+            // Null separator:      1 byte.
+            $dataparts[] = "\x00";
+            // Text:                0 or more bytes.
+            $dataparts[] = $value;
+        } else if ($type === 'tEXt') {
+            // Textual data (tEXt).
+            // Keyword:             1-79 bytes (character string).
+            $dataparts[] = $key;
+            // Null separator:      1 byte.
+            $dataparts[] = "\0";
+            // Text:                n bytes (character string).
+            $dataparts[] = $value;
+        } else {
+            throw new \moodle_exception('Unsupported chunk type: ' . $type);
+        }
+
+        $data = implode($dataparts);
+
         $crc = pack("N", crc32($type . $data));
         $len = pack("N", strlen($data));
 
         // Chunk format: length + type + data + CRC.
         // CRC is a CRC-32 computed over the chunk type and chunk data.
         $newchunk = $len . $type . $data . $crc;
+        $this->_chunks[$type] = $data;
 
         $result = substr($this->_contents, 0, $this->_size - 12)
                 . $newchunk
index d9687de..96e18fb 100644 (file)
@@ -121,7 +121,7 @@ class main implements renderable, templatable {
      *
      * @var boolean
      */
-    private $displaygroupingstarred;
+    private $displaygroupingfavourites;
 
     /**
      * Store a course grouping option setting.
@@ -214,7 +214,7 @@ class main implements renderable, templatable {
         $this->displaygroupinginprogress = $config->displaygroupinginprogress;
         $this->displaygroupingfuture = $config->displaygroupingfuture;
         $this->displaygroupingpast = $config->displaygroupingpast;
-        $this->displaygroupingstarred = $config->displaygroupingstarred;
+        $this->displaygroupingfavourites = $config->displaygroupingfavourites;
         $this->displaygroupinghidden = $config->displaygroupinghidden;
         $this->displaygroupingcustomfield = ($config->displaygroupingcustomfield && $config->customfiltergrouping);
         $this->customfiltergrouping = $config->customfiltergrouping;
@@ -226,7 +226,7 @@ class main implements renderable, templatable {
                 $this->displaygroupinginprogress,
                 $this->displaygroupingfuture,
                 $this->displaygroupingpast,
-                $this->displaygroupingstarred,
+                $this->displaygroupingfavourites,
                 $this->displaygroupinghidden);
         $displaygroupingselectorscount = count(array_filter($displaygroupingselectors));
         if ($displaygroupingselectorscount > 1 || $this->displaygroupingcustomfield) {
@@ -259,7 +259,7 @@ class main implements renderable, templatable {
         if ($config->displaygroupingpast == true) {
             return BLOCK_MYOVERVIEW_GROUPING_PAST;
         }
-        if ($config->displaygroupingstarred == true) {
+        if ($config->displaygroupingfavourites == true) {
             return BLOCK_MYOVERVIEW_GROUPING_FAVOURITES;
         }
         if ($config->displaygroupinghidden == true) {
@@ -439,7 +439,7 @@ class main implements renderable, templatable {
             'displaygroupinginprogress' => $this->displaygroupinginprogress,
             'displaygroupingfuture' => $this->displaygroupingfuture,
             'displaygroupingpast' => $this->displaygroupingpast,
-            'displaygroupingstarred' => $this->displaygroupingstarred,
+            'displaygroupingfavourites' => $this->displaygroupingfavourites,
             'displaygroupinghidden' => $this->displaygroupinghidden,
             'displaygroupingselector' => $this->displaygroupingselector,
             'displaygroupingcustomfield' => $this->displaygroupingcustomfield && $customfieldvalues,
index fc4c890..d9bccdd 100644 (file)
@@ -31,7 +31,7 @@ defined('MOODLE_INTERNAL') || die();
  * @param int $oldversion
  */
 function xmldb_block_myoverview_upgrade($oldversion) {
-    global $DB;
+    global $DB, $CFG, $OUTPUT;
 
     if ($oldversion < 2019091800) {
         // Remove orphaned course favourites, which weren't being deleted when the course was deleted.
@@ -58,5 +58,25 @@ function xmldb_block_myoverview_upgrade($oldversion) {
     // Automatically generated Moodle v3.8.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019111801) {
+        // Renaming the setting from displaygroupingstarred to displaygroupingfavourites to match Moodle convention.
+
+        // Check to see if record exists. get_config doesn't allow differentiation between not exists and false.
+        $dbval = $DB->get_field('config_plugins', 'value', ['plugin' => 'block_myoverview', 'name' => 'displaygroupingstarred']);
+        if ($dbval !== false) {
+            set_config('displaygroupingfavourites', $dbval, 'block_myoverview');
+            unset_config('displaygroupingstarred', 'block_myoverview');
+        }
+
+        if (isset($CFG->forced_plugin_settings['block_myoverview']['displaygroupingstarred'])) {
+            // Check to see if the starred setting is defined in the config file. Display a warning if so.
+            $warn = 'Setting block_myoverview->displaygroupingstarred has been renamed '.
+                    'to block_myoverview->displaygroupingfavourites. Old setting present in config.php.';
+            echo $OUTPUT->notification($warn, 'notifyproblem');
+        }
+
+        upgrade_block_savepoint(true, 2019111801, 'myoverview', false);
+    }
+
     return true;
 }
index 21f9b70..7af8cbd 100644 (file)
@@ -148,7 +148,7 @@ function block_myoverview_user_preferences() {
  * @param stdClass $course The deleted course
  */
 function block_myoverview_pre_course_delete(\stdClass $course) {
-    // Removing any starred courses which have been created for users, for this course.
+    // Removing any favourited courses which have been created for users, for this course.
     $service = \core_favourites\service_factory::get_service_for_component('core_course');
     $service->delete_favourites_by_type_and_item('courses', $course->id);
 }
index 0ec6ebe..c6bcdf4 100644 (file)
@@ -110,7 +110,7 @@ if ($ADMIN->fulltree) {
     $settings->hide_if('block_myoverview/customfiltergrouping', 'block_myoverview/displaygroupingcustomfield');
 
     $settings->add(new admin_setting_configcheckbox(
-            'block_myoverview/displaygroupingstarred',
+            'block_myoverview/displaygroupingfavourites',
             get_string('favourites', 'block_myoverview'),
             '',
             1));
index 5d6b598..ed38dc9 100644 (file)
@@ -33,7 +33,7 @@
         "displaygroupinginprogress": true,
         "displaygroupingfuture": true,
         "displaygroupingpast": true,
-        "displaygroupingstarred": true,
+        "displaygroupingfavourites": true,
         "displaygroupinghidden": true,
         "displaygroupingselector": true
     }
                 </li>
             {{/customfieldvalues}}
         {{/displaygroupingcustomfield}}
-        {{#displaygroupingstarred}}
+        {{#displaygroupingfavourites}}
         <li class="dropdown-divider" role="presentation">
             <span class="filler">&nbsp;</span>
         </li>
             <a class="dropdown-item {{#favourites}}active{{/favourites}}" href="#" data-filter="grouping" data-value="favourites"  data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} favourites, block_myoverview {{/str}}
             </a>
-        {{/displaygroupingstarred}}
+        {{/displaygroupingfavourites}}
         {{#displaygroupinghidden}}
         <li class="dropdown-divider" role="presentation">
             <span class="filler">&nbsp;</span>
index d7ee593..72d2f2c 100644 (file)
@@ -128,6 +128,20 @@ Feature: The my overview block allows users to easily access their courses
     And I should not see "Course 3" in the "Course overview" "block"
     And I should not see "Course 4" in the "Course overview" "block"
 
+  Scenario: View favourite courses - w/ persistence
+    Given I log in as "student1"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "All (except removed from view)" "button" in the "Course overview" "block"
+    When I click on "Starred" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Starred" in the "Course overview" "block"
+    And I should see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 3" in the "Course overview" "block"
+    And I should not see "Course 4" in the "Course overview" "block"
+    And I should not see "Course 5" in the "Course overview" "block"
+
   Scenario: List display  persistence
     Given I log in as "student1"
     And I click on "Display drop-down menu" "button" in the "Course overview" "block"
index bc80abe..eba4b29 100644 (file)
@@ -109,7 +109,7 @@ class block_myoverview_testcase extends advanced_testcase {
         $this->assertEquals(1, $configs->plugin->displaygroupinghidden);
         $this->assertEquals(1, $configs->plugin->displaygroupinginprogress);
         $this->assertEquals(1, $configs->plugin->displaygroupingpast);
-        $this->assertEquals(1, $configs->plugin->displaygroupingstarred);
+        $this->assertEquals(1, $configs->plugin->displaygroupingfavourites);
         $this->assertEquals('card,list,summary', $configs->plugin->layouts);
         $this->assertEquals(get_config('block_myoverview', 'version'), $configs->plugin->version);
         // Test custom fields.
index e2d27b8..76588f9 100644 (file)
@@ -2,4 +2,8 @@ This file describes API changes in the myoverview block code.
 
 === 3.7 ===
 
-* The 'block/myoverview:addinstance' capability has been removed. It has never been used in code.
\ No newline at end of file
+* The 'block/myoverview:addinstance' capability has been removed. It has never been used in code.
+
+=== 3.9 ===
+
+* Rename setting block_myoverview->displaygroupingstarred to block_myoverview->displaygroupingfavourites.
index 8b31314..e75d2cf 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;         // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019111801;         // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200;         // Requires this Moodle version.
 $plugin->component = 'block_myoverview'; // Full name of the plugin (used for diagnostics).
index dbd6386..1b2a7df 100644 (file)
@@ -16,4 +16,4 @@ Feature: Add URL to main menu block
       | External URL | http://www.google.com |
       | id_display | In pop-up |
     Then "google" "link" should exist in the "Main menu" "block"
-    And "Add an activity or resource" "link" should exist in the "Main menu" "block"
+    And "Add an activity or resource" "button" should exist in the "Main menu" "block"
index 33deb9a..0b9adfc 100644 (file)
@@ -52,11 +52,23 @@ require_once($CFG->dirroot.'/calendar/lib.php');
 $categoryid = optional_param('category', null, PARAM_INT);
 $courseid = optional_param('course', SITEID, PARAM_INT);
 $view = optional_param('view', 'upcoming', PARAM_ALPHA);
+$day = optional_param('cal_d', 0, PARAM_INT);
+$mon = optional_param('cal_m', 0, PARAM_INT);
+$year = optional_param('cal_y', 0, PARAM_INT);
 $time = optional_param('time', 0, PARAM_INT);
 $lookahead = optional_param('lookahead', null, PARAM_INT);
 
 $url = new moodle_url('/calendar/view.php');
 
+// If a day, month and year were passed then convert it to a timestamp. If these were passed
+// then we can assume the day, month and year are passed as Gregorian, as no where in core
+// should we be passing these values rather than the time. This is done for BC.
+if (!empty($day) && !empty($mon) && !empty($year)) {
+    if (checkdate($mon, $day, $year)) {
+        $time = make_timestamp($year, $mon, $day);
+    }
+}
+
 if (empty($time)) {
     $time = time();
 }
index 600f8f8..ba22662 100644 (file)
@@ -4802,6 +4802,40 @@ class api {
         $DB->delete_records(template_cohort::TABLE, array('cohortid' => $cohort->id));
     }
 
+    /**
+     * Action to perform when a user is deleted.
+     *
+     * @param int $userid The user id.
+     */
+    public static function hook_user_deleted($userid) {
+        global $DB;
+
+        $usercompetencies = $DB->get_records(user_competency::TABLE, ['userid' => $userid], '', 'id');
+        foreach ($usercompetencies as $usercomp) {
+            $DB->delete_records(evidence::TABLE, ['usercompetencyid' => $usercomp->id]);
+        }
+
+        $DB->delete_records(user_competency::TABLE, ['userid' => $userid]);
+        $DB->delete_records(user_competency_course::TABLE, ['userid' => $userid]);
+        $DB->delete_records(user_competency_plan::TABLE, ['userid' => $userid]);
+
+        // Delete any associated files.
+        $fs = get_file_storage();
+        $context = context_user::instance($userid);
+        $userevidences = $DB->get_records(user_evidence::TABLE, ['userid' => $userid], '', 'id');
+        foreach ($userevidences as $userevidence) {
+            $DB->delete_records(user_evidence_competency::TABLE, ['userevidenceid' => $userevidence->id]);
+            $DB->delete_records(user_evidence::TABLE, ['id' => $userevidence->id]);
+            $fs->delete_area_files($context->id, 'core_competency', 'userevidence', $userevidence->id);
+        }
+
+        $userplans = $DB->get_records(plan::TABLE, ['userid' => $userid], '', 'id');
+        foreach ($userplans as $userplan) {
+            $DB->delete_records(plan_competency::TABLE, ['planid' => $userplan->id]);
+            $DB->delete_records(plan::TABLE, ['id' => $userplan->id]);
+        }
+    }
+
     /**
      * Manually grade a user competency.
      *
index e8be7c4..c1890ea 100644 (file)
@@ -207,4 +207,47 @@ class core_competency_hooks_testcase extends advanced_testcase {
         $this->assertEquals(1, \core_competency\template_cohort::count_records(array('templateid' => $t1->get('id'))));
         $this->assertEquals(0, \core_competency\template_cohort::count_records(array('templateid' => $t2->get('id'))));
     }
+
+    public function test_hook_user_deleted() {
+        $this->resetAfterTest();
+        $dg = $this->getDataGenerator();
+        $ccg = $dg->get_plugin_generator('core_competency');
+
+        $u1 = $dg->create_user();
+
+        $framework = $ccg->create_framework();
+        $comp1 = $ccg->create_competency(['competencyframeworkid' => $framework->get('id')]);
+        $comp2 = $ccg->create_competency(['competencyframeworkid' => $framework->get('id')]);
+
+        $c1 = $dg->create_course();
+        $cc1a = $ccg->create_course_competency(['competencyid' => $comp1->get('id'), 'courseid' => $c1->id]);
+        $cc1b = $ccg->create_course_competency(['competencyid' => $comp2->get('id'), 'courseid' => $c1->id]);
+        $assign1a = $dg->create_module('assign', ['course' => $c1]);
+        $assign1b = $dg->create_module('assign', ['course' => $c1]);
+        $cmc1a = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign1a->cmid]);
+        $cmc1b = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign1b->cmid]);
+        $ucc1a = $ccg->create_user_competency_course(['competencyid' => $comp1->get('id'), 'courseid' => $c1->id,
+            'userid' => $u1->id]);
+        $ucc1b = $ccg->create_user_competency_course(['competencyid' => $comp2->get('id'), 'courseid' => $c1->id,
+            'userid' => $u1->id]);
+
+        $c2 = $dg->create_course();
+        $cc2a = $ccg->create_course_competency(['competencyid' => $comp1->get('id'), 'courseid' => $c2->id]);
+        $cc2b = $ccg->create_course_competency(['competencyid' => $comp2->get('id'), 'courseid' => $c2->id]);
+        $assign2a = $dg->create_module('assign', ['course' => $c2]);
+        $assign2b = $dg->create_module('assign', ['course' => $c2]);
+        $cmc2a = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign2a->cmid]);
+        $cmc2b = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign2b->cmid]);
+        $ucc2a = $ccg->create_user_competency_course(['competencyid' => $comp1->get('id'), 'courseid' => $c2->id,
+            'userid' => $u1->id]);
+        $ucc2b = $ccg->create_user_competency_course(['competencyid' => $comp2->get('id'), 'courseid' => $c2->id,
+            'userid' => $u1->id]);
+
+        reset_course_userdata((object) ['id' => $c1->id, 'reset_competency_ratings' => true]);
+
+        delete_user($u1);
+
+        // Assert the records don't exist anymore.
+        $this->assertEquals(0, user_competency_course::count_records(['courseid' => $c1->id, 'userid' => $u1->id]));
+    }
 }
index 484a7d7..5da7105 100644 (file)
@@ -521,9 +521,9 @@ $CFG->admin = 'admin';
 //      $CFG->supportuserid = -20;
 //
 // Moodle 2.7 introduces a locking api for critical tasks (e.g. cron).
-// The default locking system to use is DB locking for Postgres, and file locking for
-// MySQL, Oracle and SQLServer. If $CFG->preventfilelocking is set, then the default
-// will always be DB locking. It can be manually set to one of the lock
+// The default locking system to use is DB locking for Postgres, MySQL, MariaDB and
+// file locking for Oracle and SQLServer. If $CFG->preventfilelocking is set, then the
+// default will always be DB locking. It can be manually set to one of the lock
 // factory classes listed below, or one of your own custom classes implementing the
 // \core\lock\lock_factory interface.
 //
@@ -537,6 +537,8 @@ $CFG->admin = 'admin';
 //
 // "\\core\\lock\\db_record_lock_factory" - DB locking based on table rows.
 //
+// "\\core\\lock\\mysql_lock_factory" - DB locking based on MySQL / MariaDB locks.
+//
 // "\\core\\lock\\postgres_lock_factory" - DB locking based on postgres advisory locks.
 //
 // Settings used by the lock factories
diff --git a/course/amd/build/activitychooser.min.js b/course/amd/build/activitychooser.min.js
new file mode 100644 (file)
index 0000000..8d1704d
Binary files /dev/null and b/course/amd/build/activitychooser.min.js differ
diff --git a/course/amd/build/activitychooser.min.js.map b/course/amd/build/activitychooser.min.js.map
new file mode 100644 (file)
index 0000000..45c977b
Binary files /dev/null and b/course/amd/build/activitychooser.min.js.map differ
diff --git a/course/amd/build/local/activitychooser/dialogue.min.js b/course/amd/build/local/activitychooser/dialogue.min.js
new file mode 100644 (file)
index 0000000..b28b3db
Binary files /dev/null and b/course/amd/build/local/activitychooser/dialogue.min.js differ
diff --git a/course/amd/build/local/activitychooser/dialogue.min.js.map b/course/amd/build/local/activitychooser/dialogue.min.js.map
new file mode 100644 (file)
index 0000000..5ca8140
Binary files /dev/null and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
diff --git a/course/amd/build/local/activitychooser/repository.min.js b/course/amd/build/local/activitychooser/repository.min.js
new file mode 100644 (file)
index 0000000..a821358
Binary files /dev/null and b/course/amd/build/local/activitychooser/repository.min.js differ
diff --git a/course/amd/build/local/activitychooser/repository.min.js.map b/course/amd/build/local/activitychooser/repository.min.js.map
new file mode 100644 (file)
index 0000000..0d35705
Binary files /dev/null and b/course/amd/build/local/activitychooser/repository.min.js.map differ
diff --git a/course/amd/build/local/activitychooser/selectors.min.js b/course/amd/build/local/activitychooser/selectors.min.js
new file mode 100644 (file)
index 0000000..d826dbf
Binary files /dev/null and b/course/amd/build/local/activitychooser/selectors.min.js differ
diff --git a/course/amd/build/local/activitychooser/selectors.min.js.map b/course/amd/build/local/activitychooser/selectors.min.js.map
new file mode 100644 (file)
index 0000000..229f9dd
Binary files /dev/null and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
diff --git a/course/amd/build/recommendations.min.js b/course/amd/build/recommendations.min.js
new file mode 100644 (file)
index 0000000..59b3a55
Binary files /dev/null and b/course/amd/build/recommendations.min.js differ
diff --git a/course/amd/build/recommendations.min.js.map b/course/amd/build/recommendations.min.js.map
new file mode 100644 (file)
index 0000000..aea6e7f
Binary files /dev/null and b/course/amd/build/recommendations.min.js.map differ
diff --git a/course/amd/src/activitychooser.js b/course/amd/src/activitychooser.js
new file mode 100644 (file)
index 0000000..709cd91
--- /dev/null
@@ -0,0 +1,166 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A type of dialogue used as for choosing modules in a course.
+ *
+ * @module     core_course/activitychooser
+ * @package    core_course
+ * @copyright  2020 Mathew May <mathew.solutions>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';
+import * as Repository from 'core_course/local/activitychooser/repository';
+import selectors from 'core_course/local/activitychooser/selectors';
+import CustomEvents from 'core/custom_interaction_events';
+import * as Templates from 'core/templates';
+import * as ModalFactory from 'core/modal_factory';
+import {get_string as getString} from 'core/str';
+import Pending from 'core/pending';
+
+/**
+ * Set up the activity chooser.
+ *
+ * @method init
+ * @param {Number} courseId Course ID to use later on in fetchModules()
+ */
+export const init = courseId => {
+    const pendingPromise = new Pending();
+
+    registerListenerEvents(courseId);
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Once a selection has been made make the modal & module information and pass it along
+ *
+ * @method registerListenerEvents
+ * @param {Number} courseId
+ */
+const registerListenerEvents = (courseId) => {
+    const events = [
+        'click',
+        CustomEvents.events.activate,
+        CustomEvents.events.keyboardActivate
+    ];
+
+    const fetchModuleData = (() => {
+        let innerPromise = null;
+
+        return () => {
+            if (!innerPromise) {
+                innerPromise = new Promise((resolve) => {
+                    resolve(Repository.activityModules(courseId));
+                });
+            }
+
+            return innerPromise;
+        };
+    })();
+
+    CustomEvents.define(document, events);
+
+    // Display module chooser event listeners.
+    events.forEach((event) => {
+        document.addEventListener(event, async(e) => {
+            if (e.target.closest(selectors.elements.sectionmodchooser)) {
+                const caller = e.target.closest(selectors.elements.sectionmodchooser);
+                const builtModuleData = sectionIdMapper(await fetchModuleData(), caller.dataset.sectionid);
+                const sectionModal = await modalBuilder(builtModuleData);
+
+                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData);
+            }
+        });
+    });
+};
+
+/**
+ * Given the web service data and an ID we want to make a deep copy
+ * of the WS data then add on the section ID to the addoption URL
+ *
+ * @method sectionIdMapper
+ * @param {Object} webServiceData Our original data from the Web service call
+ * @param {Array} id The ID of the section we need to append to the links
+ * @return {Array} [modules] with URL's built
+ */
+const sectionIdMapper = (webServiceData, id) => {
+    // We need to take a fresh deep copy of the original data as an object is a reference type.
+    const newData = JSON.parse(JSON.stringify(webServiceData));
+    newData.content_items.forEach((module) => {
+        module.link += '&section=' + id;
+    });
+    return newData.content_items;
+};
+
+/**
+ * Build a modal for each section ID and store it into a map for quick access
+ *
+ * @method modalBuilder
+ * @param {Map} data our map of section ID's & modules to generate modals for
+ * @return {Object} TODO
+ */
+const modalBuilder = data => buildModal(templateDataBuilder(data));
+
+/**
+ * Given an array of modules we want to figure out where & how to place them into our template object
+ *
+ * @method templateDataBuilder
+ * @param {Array} data our modules to manipulate into a Templatable object
+ * @return {Object} Our built object ready to render out
+ */
+const templateDataBuilder = (data) => {
+    // Filter the incoming data to find favourite & recommended modules.
+    const favourites = [];
+    const recommended = data.filter(mod => mod.recommended === true);
+
+    // Given the results of the above filters lets figure out what tab to set active.
+
+    // We have some favourites.
+    const favouritesFirst = !!favourites.length;
+    // Check if we have no favourites but have some recommended.
+    const recommendedFirst = !!(recommended.length && favouritesFirst === false);
+    // We have nothing fallback to show all modules.
+    const fallback = favouritesFirst === false && recommendedFirst === false;
+
+    return {
+        'default': data,
+        favourites: favourites,
+        recommended: recommended,
+        favouritesFirst: favouritesFirst,
+        recommendedFirst: recommendedFirst,
+        fallback: fallback,
+    };
+};
+
+/**
+ * Given an object we want to prebuild a modal ready to store into a map
+ *
+ * @method buildModal
+ * @param {Object} data The template data which contains arrays of modules
+ * @return {Object} The modal for the calling section with everything already set up
+ */
+const buildModal = data => {
+    return ModalFactory.create({
+        type: ModalFactory.types.DEFAULT,
+        title: getString('addresourceoractivity'),
+        body: Templates.render('core_course/chooser', data),
+        large: true,
+        templateContext: {
+            classes: 'modchooser'
+        }
+    });
+};
diff --git a/course/amd/src/local/activitychooser/dialogue.js b/course/amd/src/local/activitychooser/dialogue.js
new file mode 100644 (file)
index 0000000..f443d9a
--- /dev/null
@@ -0,0 +1,347 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A type of dialogue used as for choosing options.
+ *
+ * @module     core_course/local/chooser/dialogue
+ * @package    core
+ * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import * as ModalEvents from 'core/modal_events';
+import selectors from 'core_course/local/activitychooser/selectors';
+import * as Templates from 'core/templates';
+import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
+import {addIconToContainer} from 'core/loadingicon';
+
+/**
+ * Given an event from the main module 'page' navigate to it's help section via a carousel.
+ *
+ * @method showModuleHelp
+ * @param {jQuery} carousel Our initialized carousel to manipulate
+ * @param {Object} moduleData Data of the module to carousel to
+ */
+const showModuleHelp = (carousel, moduleData) => {
+    const help = carousel.find(selectors.regions.help)[0];
+    help.innerHTML = '';
+
+    // Add a spinner.
+    const spinnerPromise = addIconToContainer(help);
+
+    // Used later...
+    let transitionPromiseResolver = null;
+    const transitionPromise = new Promise(resolve => {
+        transitionPromiseResolver = resolve;
+    });
+
+    // Build up the html & js ready to place into the help section.
+    const contentPromise = Templates.renderForPromise('core_course/chooser_help', moduleData);
+
+    // Wait for the content to be ready, and for the transition to be complet.
+    Promise.all([contentPromise, spinnerPromise, transitionPromise])
+        .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
+        .then(() => {
+            help.querySelector(selectors.regions.chooserSummary.description).focus();
+            return help;
+        })
+        .catch(Notification.exception);
+
+    // Move to the next slide, and resolve the transition promise when it's done.
+    carousel.one('slid.bs.carousel', () => {
+        transitionPromiseResolver();
+    });
+    // Trigger the transition between 'pages'.
+    carousel.carousel('next');
+};
+
+/**
+ * Register chooser related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {Promise} modal Our modal that we are working with
+ * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
+ */
+const registerListenerEvents = (modal, mappedModules) => {
+    const bodyClickListener = e => {
+        if (e.target.closest(selectors.actions.optionActions.showSummary)) {
+            const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
+
+            const module = e.target.closest(selectors.regions.chooserOption.container);
+            const moduleName = module.dataset.modname;
+            const moduleData = mappedModules.get(moduleName);
+            showModuleHelp(carousel, moduleData);
+        }
+
+        // From the help screen go back to the module overview.
+        if (e.target.matches(selectors.actions.closeOption)) {
+            const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
+
+            // Trigger the transition between 'pages'.
+            carousel.carousel('prev');
+            carousel.on('slid.bs.carousel', () => {
+                const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);
+                const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
+                caller.focus();
+            });
+        }
+    };
+
+    modal.getBodyPromise()
+
+    // The return value of getBodyPromise is a jquery object containing the body NodeElement.
+    .then(body => body[0])
+
+    // Set up the carousel.
+    .then(body => {
+        $(body.querySelector(selectors.regions.carousel))
+            .carousel({
+                interval: false,
+                pause: true,
+                keyboard: false
+            });
+
+        return body;
+    })
+
+    // Add the listener for clicks on the body.
+    .then(body => {
+        body.addEventListener('click', bodyClickListener);
+        return body;
+    })
+
+    // Register event listeners related to the keyboard navigation controls.
+    .then(body => {
+        initKeyboardNavigation(body, mappedModules);
+        return body;
+    })
+    .catch();
+
+};
+
+/**
+ * Initialise the keyboard navigation controls for the chooser.
+ *
+ * @method initKeyboardNavigation
+ * @param {HTMLElement} body Our modal that we are working with
+ * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
+ */
+const initKeyboardNavigation = (body, mappedModules) => {
+
+    // Set up the tab handlers.
+    const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
+    const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
+    const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
+    const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav];
+    tabNavArray.forEach((element) => {
+        return element.addEventListener('keyup', (e) => {
+            const firstLink = e.target.parentElement.parentElement.firstElementChild.firstElementChild;
+            const lastLink = e.target.parentElement.parentElement.lastElementChild.firstElementChild;
+
+            if (e.keyCode === arrowRight) {
+                const nextLink = e.target.parentElement.nextElementSibling;
+                if (nextLink === null) {
+                    e.srcElement.tabIndex = -1;
+                    firstLink.tabIndex = 0;
+                    firstLink.focus();
+                } else if (nextLink.firstElementChild.classList.contains('d-none')) {
+                    e.srcElement.tabIndex = -1;
+                    lastLink.tabIndex = 0;
+                    lastLink.focus();
+                } else {
+                    e.srcElement.tabIndex = -1;
+                    nextLink.firstElementChild.tabIndex = 0;
+                    nextLink.firstElementChild.focus();
+                }
+            }
+            if (e.keyCode === arrowLeft) {
+                const previousLink = e.target.parentElement.previousElementSibling;
+                if (previousLink === null) {
+                    e.srcElement.tabIndex = -1;
+                    lastLink.tabIndex = 0;
+                    lastLink.focus();
+                } else if (previousLink.firstElementChild.classList.contains('d-none')) {
+                    e.srcElement.tabIndex = -1;
+                    firstLink.tabIndex = 0;
+                    firstLink.focus();
+                } else {
+                    e.srcElement.tabIndex = -1;
+                    previousLink.firstElementChild.tabIndex = 0;
+                    previousLink.firstElementChild.focus();
+                }
+            }
+            if (e.keyCode === home) {
+                e.srcElement.tabIndex = -1;
+                firstLink.tabIndex = 0;
+                firstLink.focus();
+            }
+            if (e.keyCode === end) {
+                e.srcElement.tabIndex = -1;
+                lastLink.tabIndex = 0;
+                lastLink.focus();
+            }
+            if (e.keyCode === space) {
+                e.preventDefault();
+                e.target.click();
+            }
+        });
+    });
+
+    // Set up the handlers for the modules.
+    const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
+
+    Array.from(chooserOptions).forEach((element) => {
+        return element.addEventListener('keyup', (e) => {
+            const chooserOptions = document.querySelector(selectors.regions.chooserOptions);
+
+            // Check for enter/ space triggers for showing the help.
+            if (e.keyCode === enter || e.keyCode === space) {
+                if (e.target.matches(selectors.actions.optionActions.showSummary)) {
+                    e.preventDefault();
+                    const module = e.target.closest(selectors.regions.chooserOption.container);
+                    const moduleName = module.dataset.modname;
+                    const moduleData = mappedModules.get(moduleName);
+                    const carousel = $(body.querySelector(selectors.regions.carousel));
+                    carousel.carousel({
+                        interval: false,
+                        pause: true,
+                        keyboard: false
+                    });
+                    showModuleHelp(carousel, moduleData);
+                }
+            }
+
+            // Next.
+            if (e.keyCode === arrowRight) {
+                e.preventDefault();
+                const currentOption = e.target.closest(selectors.regions.chooserOption.container);
+                const nextOption = currentOption.nextElementSibling;
+                const firstOption = chooserOptions.firstElementChild;
+                const toFocusOption = clickErrorHandler(nextOption, firstOption);
+                focusChooserOption(toFocusOption, currentOption);
+            }
+
+            // Previous.
+            if (e.keyCode === arrowLeft) {
+                e.preventDefault();
+                const currentOption = e.target.closest(selectors.regions.chooserOption.container);
+                const previousOption = currentOption.previousElementSibling;
+                const lastOption = chooserOptions.lastElementChild;
+                const toFocusOption = clickErrorHandler(previousOption, lastOption);
+                focusChooserOption(toFocusOption, currentOption);
+            }
+
+            if (e.keyCode === home) {
+                e.preventDefault();
+                const currentOption = e.target.closest(selectors.regions.chooserOption.container);
+                const firstOption = chooserOptions.firstElementChild;
+                focusChooserOption(firstOption, currentOption);
+            }
+
+            if (e.keyCode === end) {
+                e.preventDefault();
+                const currentOption = e.target.closest(selectors.regions.chooserOption.container);
+                const lastOption = chooserOptions.lastElementChild;
+                focusChooserOption(lastOption, currentOption);
+            }
+        });
+    });
+};
+
+/**
+ * Focus on a chooser option element and remove the previous chooser element from the focus order
+ *
+ * @method focusChooserOption
+ * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
+ * @param {HTMLElement} previousChooserOption The previous focused option element
+ */
+const focusChooserOption = (currentChooserOption, previousChooserOption = false) => {
+    if (previousChooserOption !== false) {
+        const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
+        const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+        // Set tabindex to -1 to remove the previous chooser option element from the focus order.
+        previousChooserOption.tabIndex = -1;
+        previousChooserOptionLink.tabIndex = -1;
+        previousChooserOptionHelp.tabIndex = -1;
+    }
+
+    const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
+    const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+    // Set tabindex to 0 to add current chooser option element to the focus order.
+    currentChooserOption.tabIndex = 0;
+    currentChooserOptionLink.tabIndex = 0;
+    currentChooserOptionHelp.tabIndex = 0;
+    // Focus the current chooser option element.
+    currentChooserOption.focus();
+};
+
+/**
+ * Small error handling function to make sure the navigated to object exists
+ *
+ * @method clickErrorHandler
+ * @param {HTMLElement} item What we want to check exists
+ * @param {HTMLElement} fallback If we dont match anything fallback the focus
+ * @return {String}
+ */
+const clickErrorHandler = (item, fallback) => {
+    if (item !== null) {
+        return item;
+    } else {
+        return fallback;
+    }
+};
+
+/**
+ * Display the module chooser.
+ *
+ * @method displayChooser
+ * @param {HTMLElement} origin The calling button
+ * @param {Object} modal Our created modal for the section
+ * @param {Array} sectionModules An array of all of the built module information
+ */
+export const displayChooser = (origin, modal, sectionModules) => {
+
+    // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
+    const mappedModules = new Map();
+    sectionModules.forEach((module) => {
+        mappedModules.set(module.componentname + '_' + module.link, module);
+    });
+
+    // Register event listeners.
+    registerListenerEvents(modal, mappedModules);
+
+    // We want to focus on the action select when the dialog is closed.
+    modal.getRoot().on(ModalEvents.hidden, () => {
+        modal.destroy();
+    });
+
+    // We want to focus on the first chooser option element as soon as the modal is opened.
+    modal.getRoot().on(ModalEvents.shown, () => {
+        modal.getModal()[0].tabIndex = -1;
+
+        modal.getBodyPromise()
+        .then(body => {
+            const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);
+            focusChooserOption(firstChooserOption);
+
+            return;
+        })
+        .catch(Notification.exception);
+    });
+
+    modal.show();
+};
diff --git a/course/amd/src/local/activitychooser/repository.js b/course/amd/src/local/activitychooser/repository.js
new file mode 100644 (file)
index 0000000..e6a09c5
--- /dev/null
@@ -0,0 +1,40 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ *
+ * @module     core_course/repository
+ * @package    core_course
+ * @copyright  2019 Mathew May <mathew.solutions>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import ajax from 'core/ajax';
+
+/**
+ * Fetch all the information on modules we'll need in the activity chooser.
+ *
+ * @method activityModules
+ * @param {Number} courseid What course to fetch the modules for
+ * @return {object} jQuery promise
+ */
+export const activityModules = (courseid) => {
+    const request = {
+        methodname: 'core_course_get_course_content_items',
+        args: {
+            courseid: courseid,
+        },
+    };
+    return ajax.call([request])[0];
+};
diff --git a/course/amd/src/local/activitychooser/selectors.js b/course/amd/src/local/activitychooser/selectors.js
new file mode 100644 (file)
index 0000000..f00b620
--- /dev/null
@@ -0,0 +1,76 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Define all of the selectors we will be using on the grading interface.
+ *
+ * @module     core_course/local/chooser/selectors
+ * @package    core_course
+ * @copyright  2019 Mathew May <mathew.solutions>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A small helper function to build queryable data selectors.
+ * @method getDataSelector
+ * @param {String} name
+ * @param {String} value
+ * @return {string}
+ */
+const getDataSelector = (name, value) => {
+    return `[data-${name}="${value}"]`;
+};
+
+export default {
+    regions: {
+        chooser: getDataSelector('region', 'chooser-container'),
+        chooserOptions: getDataSelector('region', 'chooser-options-container'),
+        chooserOption: {
+            container: getDataSelector('region', 'chooser-option-container'),
+            actions: getDataSelector('region', 'chooser-option-actions-container'),
+            info: getDataSelector('region', 'chooser-option-info-container'),
+        },
+        chooserSummary: {
+            container: getDataSelector('region', 'chooser-option-summary-container'),
+            content: getDataSelector('region', 'chooser-option-summary-content-container'),
+            description: getDataSelector('region', 'summary-description'),
+            actions: getDataSelector('region', 'chooser-option-summary-actions-container'),
+        },
+        carousel: getDataSelector('region', 'carousel'),
+        help: getDataSelector('region', 'help'),
+        modules: getDataSelector('region', 'modules'),
+        favouriteTabNav: getDataSelector('region', 'favourite-tab-nav'),
+        recommendedTabNav: getDataSelector('region', 'recommended-tab-nav'),
+        defaultTabNav: getDataSelector('region', 'default-tab-nav'),
+        favouriteTab: getDataSelector('region', 'favourites'),
+        recommendedTab: getDataSelector('region', 'recommended'),
+        defaultTab: getDataSelector('region', 'default'),
+        getModuleSelector: modname => `[role="menuitem"][data-modname="${modname}"]`
+    },
+    actions: {
+        optionActions: {
+            showSummary: getDataSelector('action', 'show-option-summary'),
+        },
+        addChooser: getDataSelector('action', 'add-chooser-option'),
+        closeOption: getDataSelector('action', 'close-chooser-option-summary'),
+        hide: getDataSelector('action', 'hide')
+    },
+    elements: {
+        section: '.section',
+        sectionmodchooser: 'button.section-modchooser-link',
+        sitemenu: '.block_site_main_menu',
+        sitetopic: 'div.sitetopic',
+    },
+};
diff --git a/course/amd/src/recommendations.js b/course/amd/src/recommendations.js
new file mode 100644 (file)
index 0000000..17aae87
--- /dev/null
@@ -0,0 +1,54 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle toggling activity chooser recommendations.
+ *
+ * @module     core_course/recommendations
+ * @copyright  2020 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+
+/**
+ * Do an ajax call to toggle the recommendation
+ *
+ * @param  {object} e The event
+ * @return {void}
+ */
+const toggleRecommendation = (e) => {
+    let data = {
+        methodname: 'core_course_toggle_activity_recommendation',
+        args: {
+            area: e.currentTarget.dataset.area,
+            id: e.currentTarget.dataset.id
+        }
+    };
+    Ajax.call([data])[0].fail(Notification.exception);
+};
+
+/**
+ * Initialisation function
+ *
+ * @return {void}
+ */
+export const init = () => {
+    const checkboxelements = document.querySelectorAll("[data-area]");
+    checkboxelements.forEach((checkbox) => {
+        checkbox.addEventListener('change', toggleRecommendation);
+    });
+};
index de9b8b7..aea6ed0 100644 (file)
@@ -710,17 +710,38 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * 0 - array of ids of top-level categories (always present)
      * '0i' - array of ids of top-level categories that have visible=0 (always present but may be empty array)
      * $id (int) - array of ids of categories that are direct children of category with id $id. If
-     *   category with id $id does not exist returns false. If category has no children returns empty array
+     *   category with id $id does not exist, or category has no children, returns empty array
      * $id.'i' - array of ids of children categories that have visible=0
      *
      * @param int|string $id
      * @return mixed
      */
     protected static function get_tree($id) {
+        $all = self::get_cached_cat_tree();
+        if (is_null($all) || !isset($all[$id])) {
+            // Could not get or rebuild the tree, or requested a non-existant ID.
+            return [];
+        } else {
+            return $all[$id];
+        }
+    }
+
+    /**
+     * Return the course category tree.
+     *
+     * Returns the category tree array, from the cache if available or rebuilding the cache
+     * if required. Uses locking to prevent the cache being rebuilt by multiple requests at once.
+     *
+     * @return array|null The tree as an array, or null if rebuilding the tree failed due to a lock timeout.
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws moodle_exception
+     */
+    private static function get_cached_cat_tree() : ?array {
         $coursecattreecache = cache::make('core', 'coursecattree');
-        $rv = $coursecattreecache->get($id);
-        if ($rv !== false) {
-            return $rv;
+        $all = $coursecattreecache->get('all');
+        if ($all !== false) {
+            return $all;
         }
         // Might need to rebuild the tree. Put a lock in place to ensure other requests don't try and do this in parallel.
         $lockfactory = \core\lock\lock_config::get_lock_factory('core_coursecattree');
@@ -728,26 +749,22 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 course_modinfo::COURSE_CACHE_LOCK_WAIT, course_modinfo::COURSE_CACHE_LOCK_EXPIRY);
         if ($lock === false) {
             // Couldn't get a lock to rebuild the tree.
-            return [];
+            return null;
         }
-        $rv = $coursecattreecache->get($id);
-        if ($rv !== false) {
+        $all = $coursecattreecache->get('all');
+        if ($all !== false) {
             // Tree was built while we were waiting for the lock.
             $lock->release();
-            return $rv;
+            return $all;
         }
         // Re-build the tree.
         try {
             $all = self::rebuild_coursecattree_cache_contents();
-            $coursecattreecache->set_many($all);
+            $coursecattreecache->set('all', $all);
         } finally {
             $lock->release();
         }
-        if (array_key_exists($id, $all)) {
-            return $all[$id];
-        }
-        // Requested non-existing category.
-        return array();
+        return $all;
     }
 
     /**
@@ -1364,7 +1381,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         while (count($walk) > 0) {
             $catid = array_pop($walk);
             $directchildren = self::get_tree($catid);
-            if ($directchildren !== false && count($directchildren) > 0) {
+            if (count($directchildren) > 0) {
                 $walk = array_merge($walk, $directchildren);
                 $children = array_merge($children, $directchildren);
             }
index d7badc8..dede44d 100644 (file)
@@ -68,6 +68,17 @@ class course_handler extends \core_customfield\handler {
         return self::$singleton;
     }
 
+    /**
+     * Run reset code after unit tests to reset the singleton usage.
+     */
+    public static function reset_caches(): void {
+        if (!PHPUNIT_TEST) {
+            throw new \coding_exception('This feature is only intended for use in unit tests');
+        }
+
+        static::$singleton = null;
+    }
+
     /**
      * The current user can configure custom fields on this component.
      *
diff --git a/course/classes/local/entity/content_item.php b/course/classes/local/entity/content_item.php
new file mode 100644 (file)
index 0000000..cd7c264
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the content_item class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The content_item class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item {
+    /** @var int $id the id. */
+    private $id;
+
+    /** @var string $name the name. */
+    private $name;
+
+    /** @var title $title the title. */
+    private $title;
+
+    /** @var \moodle_url $link the url for the content item's setup page (usually mod/edit.php). */
+    private $link;
+
+    /** @var string $icon an html string containing the icon for this item. */
+    private $icon;
+
+    /** @var string $help the description/help text for this content item. */
+    private $help;
+
+    /** @var int $achetype a module archetype, e.g. MOD_ARCHETYPE_RESOURCE, MOD_ARCHETYPE_OTHER. */
+    private $archetype;
+
+    /** @var string $componentname the name of the component from which this content item originates. */
+    private $componentname;
+
+    /**
+     * The content_item constructor.
+     *
+     * @param int $id Id number.
+     * @param string $name Name of the item, not human readable.
+     * @param title $title Human readable title for the item.
+     * @param \moodle_url $link The URL to the creation page, with any item specific params
+     * @param string $icon HTML containing the icon for the item
+     * @param string $help The description of the item.
+     * @param int $archetype the archetype for the content item (see MOD_ARCHETYPE_X definitions in lib/moodlelib.php).
+     * @param string $componentname the name of the component/plugin with which this content item is associated.
+     */
+    public function __construct(int $id, string $name, title $title, \moodle_url $link, string $icon, string $help,
+            int $archetype, string $componentname) {
+        $this->id = $id;
+        $this->name = $name;
+        $this->title = $title;
+        $this->link = $link;
+        $this->icon = $icon;
+        $this->help = $help;
+        $this->archetype = $archetype;
+        $this->componentname = $componentname;
+    }
+
+    /**
+     * Get the name of the component with which this content item is associated.
+     *
+     * @return string
+     */
+    public function get_component_name(): string {
+        return $this->componentname;
+    }
+
+    /**
+     * Get the help description of this item.
+     *
+     * @return string
+     */
+    public function get_help(): string {
+        return $this->help;
+    }
+
+    /**
+     * Get the archetype of this item.
+     *
+     * @return int
+     */
+    public function get_archetype(): int {
+        return $this->archetype;
+    }
+
+    /**
+     * Get the id of this item.
+     * @return int
+     */
+    public function get_id(): int {
+        return $this->id;
+    }
+
+    /**
+     * Get the name of this item.
+     *
+     * @return string
+     */
+    public function get_name(): string {
+        return $this->name;
+    }
+
+    /**
+     * Get the human readable title of this item.
+     *
+     * @return title
+     */
+    public function get_title(): title {
+        return $this->title;
+    }
+
+    /**
+     * Get the link to the creation page of this item.
+     *
+     * @return \moodle_url
+     */
+    public function get_link(): \moodle_url {
+        return $this->link;
+    }
+
+    /**
+     * Get the icon html for this item.
+     *
+     * @return string
+     */
+    public function get_icon(): string {
+        return $this->icon;
+    }
+}
diff --git a/course/classes/local/entity/lang_string_title.php b/course/classes/local/entity/lang_string_title.php
new file mode 100644 (file)
index 0000000..17b3de6
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the lang_string_title class of value object, providing access to the value of a lang string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The lang_string_title class of value object, providing access to the value of a lang string.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class lang_string_title implements title {
+
+    /** @var string $component the component name. */
+    private $component;
+
+    /** @var string $identifier the string identifier. */
+    private $identifier;
+
+    /**
+     * The lang_string_title constructor.
+     *
+     * @param string $identifier the component name.
+     * @param string $component the string identifier.
+     */
+    public function __construct(string $identifier, string $component) {
+        $this->identifier = $identifier;
+        $this->component = $component;
+    }
+
+    /**
+     * Returns the value of the wrapped string.
+     *
+     * @return string the value of the string.
+     */
+    public function get_value(): string {
+        return get_string($this->identifier, $this->component);
+    }
+}
diff --git a/course/classes/local/entity/string_title.php b/course/classes/local/entity/string_title.php
new file mode 100644 (file)
index 0000000..e7da781
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the string_title class of value object, which provides access to a simple string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The string_title class of value object, which provides access to a simple string.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class string_title implements title {
+
+    /** @var string $title the title string. */
+    private $title;
+
+    /**
+     * The string_title constructor.
+     *
+     * @param string $title a string.
+     */
+    public function __construct(string $title) {
+        $this->title = $title;
+    }
+
+    /**
+     * Return the value of the wrapped string.
+     *
+     * @return string
+     */
+    public function get_value(): string {
+        return $this->title;
+    }
+}
diff --git a/course/classes/local/entity/title.php b/course/classes/local/entity/title.php
new file mode 100644 (file)
index 0000000..2bcc4a2
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the title value object interface, which provides a basic interface to a string.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\entity;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface title {
+
+    /**
+     * Get the value of this title.
+     */
+    public function get_value(): string;
+}
diff --git a/course/classes/local/exporters/course_content_item_exporter.php b/course/classes/local/exporters/course_content_item_exporter.php
new file mode 100644 (file)
index 0000000..2386446
--- /dev/null
@@ -0,0 +1,157 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the course_content_item_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use core_course\local\entity\content_item;
+use core_course\local\service\content_item_service;
+
+/**
+ * The course_content_item_exporter class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_content_item_exporter extends exporter {
+
+    /** @var content_item $contentitem the content_item to export. */
+    private $contentitem;
+
+    /**
+     * The course_content_item_exporter constructor.
+     *
+     * @param content_item $contentitem the content item to export.
+     * @param array $related the array of related objects used during export.
+     */
+    public function __construct(content_item $contentitem, array $related = []) {
+        $this->contentitem = $contentitem;
+
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Definition of all properties originating in the export target, \core_course\local\entity\content_item.
+     *
+     * @return array The array of property values, indexed by name.
+     */
+    protected static function define_properties() {
+        return [
+            'id' => ['type' => PARAM_INT, 'description' => 'The id of the content item'],
+            'name' => ['type' => PARAM_TEXT, 'description' => 'Name of the content item'],
+            'title' => ['type' => PARAM_TEXT, 'description' => 'The string title of the content item, human readable'],
+            'link' => ['type' => PARAM_URL, 'description' => 'The link to the content item creation page'],
+            'icon' => ['type' => PARAM_RAW, 'description' => 'Html containing the icon for the content item'],
+            'help' => ['type' => PARAM_RAW, 'description' => 'Html description / help for the content item'],
+            'archetype' => ['type' => PARAM_RAW, 'description' => 'The archetype of the module exposing the content item'],
+            'componentname' => ['type' => PARAM_TEXT, 'description' => 'The name of the component exposing the content item'],
+        ];
+    }
+
+    /**
+     * Definition of all properties which are either calculated or originate in a related domain object.
+     *
+     * @return array The array of property values, indexed by name.
+     */
+    protected static function define_other_properties() {
+        // This will hold user-dependant properties such as whether the item is starred or recommended.
+        return [
+            'favourite' => ['type' => PARAM_BOOL, 'description' => 'Has the user favourited the content item'],
+            'legacyitem' => [
+                'type' => PARAM_BOOL,
+                'description' => 'If this item was pulled from the old callback and has no item id.'
+            ],
+            'recommended' => ['type' => PARAM_BOOL, 'description' => 'Has this item been recommended'],
+        ];
+    }
+
+    /**
+     * Get ALL properties for the content_item DTO being exported.
+     *
+     * These properties are a mix of:
+     * - readonly properties of the primary object (content_item) being exported.
+     * - calculated values
+     * - properties originating from the related domain objects.
+     *
+     * Normally, those properties defined in get_properties() are added to the export automatically as part of the superclass code,
+     * provided they are public properties on the export target. In this case, the export target is content_item, which doesn't
+     * provide public access to its properties, so those are fetched via their respective getters here.
+     *
+     * @param \renderer_base $output
+     * @return array The array of property values, indexed by name.
+     */
+    protected function get_other_values(\renderer_base $output) {
+
+        $favourite = false;
+        $itemtype = 'contentitem_' . $this->contentitem->get_component_name();
+        if (isset($this->related['favouriteitems'])) {
+            foreach ($this->related['favouriteitems'] as $favobj) {
+                if ($favobj->itemtype === $itemtype && in_array($this->contentitem->get_id(), $favobj->ids)) {
+                    $favourite = true;
+                }
+            }
+        }
+
+        $recommended = false;
+        $itemtype = content_item_service::RECOMMENDATION_PREFIX . $this->contentitem->get_component_name();
+        if (isset($this->related['recommended'])) {
+            foreach ($this->related['recommended'] as $favobj) {
+                if ($favobj->itemtype === $itemtype && in_array($this->contentitem->get_id(), $favobj->ids)) {
+                    $recommended = true;
+                }
+            }
+        }
+
+        $properties = [
+            'id' => $this->contentitem->get_id(),
+            'name' => $this->contentitem->get_name(),
+            'title' => $this->contentitem->get_title()->get_value(),
+            'link' => $this->contentitem->get_link()->out(false),
+            'icon' => $this->contentitem->get_icon(),
+            'help' => $this->contentitem->get_help(),
+            'archetype' => $this->contentitem->get_archetype(),
+            'componentname' => $this->contentitem->get_component_name(),
+            'favourite' => $favourite,
+            'legacyitem' => ($this->contentitem->get_id() == -1),
+            'recommended' => $recommended
+        ];
+
+        return $properties;
+    }
+
+    /**
+     * Define the list of related objects, used by this exporter.
+     *
+     * @return array the list of related objects.
+     */
+    protected static function define_related(): array {
+        return [
+            'context' => '\context',
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
+        ];
+    }
+}
diff --git a/course/classes/local/exporters/course_content_items_exporter.php b/course/classes/local/exporters/course_content_items_exporter.php
new file mode 100644 (file)
index 0000000..9b1c694
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the course_content_items_exporter class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use core_course\local\entity\content_item;
+
+/**
+ * The course_content_items_exporter class.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_content_items_exporter extends exporter {
+
+    /** @var content_item[] the array of content items. */
+    private $contentitems;
+
+    /**
+     * The course_content_items_exporter constructor.
+     *
+     * @param array $contentitems the array of \core_course\local\entity\content_item objects to export.
+     * @param array $related any related objects, see define_related for what's expected.
+     */
+    public function __construct(array $contentitems, array $related) {
+        $this->contentitems = $contentitems;
+
+        parent::__construct([], $related);
+    }
+
+    /**
+     * Return the properties defining this export.
+     *
+     * @return array the array of properties.
+     */
+    public static function define_properties() {
+        return [
+            'content_items' => [
+                'type' => course_content_item_exporter::read_properties_definition(),
+                'multiple' => true
+            ]
+        ];
+    }
+
+    /**
+     * Generate and return the data for this export.
+     *
+     * @param \renderer_base $output
+     * @return array the array of course content_items
+     */
+    protected function get_other_values(\renderer_base $output) {
+
+        $contentitemexport = function(content_item $contentitem) use ($output) {
+            $exporter = new course_content_item_exporter(
+                $contentitem,
+                [
+                    'context' => $this->related['context'],
+                    'favouriteitems' => $this->related['favouriteitems'],
+                    'recommended' => $this->related['recommended']
+                ]
+            );
+            return $exporter->export($output);
+        };
+
+        $exportedcontentitems = array_map($contentitemexport, $this->contentitems);
+
+        return [
+            'content_items' => $exportedcontentitems
+        ];
+    }
+
+    /**
+     * Define the list of related objects, used by this exporter.
+     *
+     * @return array the list of related objects.
+     */
+    protected static function define_related() {
+        return [
+            'context' => '\context',
+            'favouriteitems' => '\stdClass[]?',
+            'recommended' => '\stdClass[]?'
+        ];
+    }
+}
diff --git a/course/classes/local/factory/content_item_service_factory.php b/course/classes/local/factory/content_item_service_factory.php
new file mode 100644 (file)
index 0000000..bc58ed7
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Contains the service_factory, a locator for services for course content items.
+ *
+ * Services encapsulate the business logic, and any data manipulation code, and are what clients should interact with.
+ *
+ * @package   core_course
+ * @copyright 2020 Adrian Greeve <adrian@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\factory;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\repository\caching_content_item_readonly_repository;
+use core_course\local\repository\content_item_readonly_repository;
+use core_course\local\service\content_item_service;
+
+/**
+ * Class service_factory, providing functions for location of service objects for course content items.
+ *
+ * This class is responsible for providing service objects to clients only.
+ *
+ * @copyright 2020 Adrian Greeve <adrian@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_service_factory {
+
+    /**
+     * Returns a basic service object providing operations for course content items.
+     *
+     * @return content_item_service
+     */
+    public static function get_content_item_service(): content_item_service {
+        return new content_item_service(
+            new caching_content_item_readonly_repository(
+                \cache::make('core', 'user_course_content_items'),
+                new content_item_readonly_repository()
+            )
+        );
+    }
+}
diff --git a/course/classes/local/repository/caching_content_item_readonly_repository.php b/course/classes/local/repository/caching_content_item_readonly_repository.php
new file mode 100644 (file)
index 0000000..51c93fb
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class caching_content_item_repository, for fetching content_items, with additional caching.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The class caching_content_item_repository, for fetching content_items, with additional caching.
+ *
+ * This class decorates the content_item_repository and uses the supplied cache to store content items for user and course
+ * combinations. The content items for subsequent calls are returned from the cache if present, else are retrieved from the wrapped
+ * content_item_repository.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class caching_content_item_readonly_repository implements content_item_readonly_repository_interface {
+
+    /** @var \cache $cachestore the cache to use. */
+    private $cachestore;
+
+    /** @var content_item_readonly_repository $contentitemrepository a content item repository. */
+    private $contentitemrepository;
+
+    /**
+     * The caching_content_item_readonly_repository constructor.
+     *
+     * @param \cache $cachestore a cache to use.
+     * @param content_item_readonly_repository $contentitemrepository the repository to use as a fallback, after a cache miss.
+     */
+    public function __construct(\cache $cachestore, content_item_readonly_repository $contentitemrepository) {
+        $this->cachestore = $cachestore;
+        $this->contentitemrepository = $contentitemrepository;
+    }
+
+    /**
+     * Find all the content items for a given course and user.
+     *
+     * @param \stdClass $course The course to find content items for.
+     * @param \stdClass $user the user to pass to plugins.
+     * @return array the array of content items.
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array {
+        global $USER;
+        // Try to find this data in the cache first.
+        $key = $USER->id . '_' . $course->id;
+        $contentitems = $this->cachestore->get($key);
+        if ($contentitems !== false) {
+            return $contentitems;
+        }
+
+        // If we can't find it there, we must get it from the slow data store, updating the cache in the process.
+        $contentitems = $this->contentitemrepository->find_all_for_course($course, $user);
+        $this->cachestore->set($key, $contentitems);
+        return $contentitems;
+    }
+
+    /**
+     * Find all the content items made available by core and plugins.
+     *
+     * @return array
+     */
+    public function find_all(): array {
+        return $this->contentitemrepository->find_all();
+    }
+}
diff --git a/course/classes/local/repository/content_item_readonly_repository.php b/course/classes/local/repository/content_item_readonly_repository.php
new file mode 100644 (file)
index 0000000..b92e4fe
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains class content_item_repository, for fetching content_items.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\entity\content_item;
+use core_course\local\entity\lang_string_title;
+use core_course\local\entity\string_title;
+
+/**
+ * The class content_item_repository, for reading content_items.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_readonly_repository implements content_item_readonly_repository_interface {
+    /**
+     * Get the help string for content items representing core modules.
+     *
+     * @param string $modname the module name.
+     * @return string the help string, including help link.
+     */
+    private function get_core_module_help_string(string $modname): string {
+        global $OUTPUT;
+
+        $help = '';
+        $sm = get_string_manager();
+        if ($sm->string_exists('modulename_help', $modname)) {
+            $help = get_string('modulename_help', $modname);
+            if ($sm->string_exists('modulename_link', $modname)) { // Link to further info in Moodle docs.
+                $link = get_string('modulename_link', $modname);
+                $linktext = get_string('morehelp');
+                $help .= \html_writer::tag('div', $OUTPUT->doc_link($link, $linktext, true), ['class' => 'helpdoclink']);
+            }
+        }
+        return $help;
+    }
+
+    /**
+     * Create a content_item object based on legacy data returned from the get_shortcuts hook implementations.
+     *
+     * @param \stdClass $item the stdClass of legacy data.
+     * @return content_item a content item object.
+     */
+    private function content_item_from_legacy_data(\stdClass $item): content_item {
+        global $OUTPUT;
+
+        // Make sure the legacy data results in a content_item with id = 0.
+        // Even with an id, we can't uniquely identify the item, because we can't guarantee what component it came from.
+        // An id of -1, signifies this.
+        $item->id = -1;
+
+        // If the module provides the helplink property, append it to the help text to match the look and feel
+        // of the default course modules.
+        if (isset($item->help) && isset($item->helplink)) {
+            $linktext = get_string('morehelp');
+            $item->help .= \html_writer::tag('div',
+                $OUTPUT->doc_link($item->helplink, $linktext, true), ['class' => 'helpdoclink']);
+        }
+
+        if (is_string($item->title)) {
+            $item->title = new string_title($item->title);
+        } else if ($item->title instanceof \lang_string) {
+            $item->title = new lang_string_title($item->title->get_identifier(), $item->title->get_component());
+        }
+
+        // Legacy items had names which are in one of 2 forms:
+        // modname, i.e. 'assign' or
+        // modname:link, i.e. lti:http://etc...
+        // We need to grab the module name out to create the componentname.
+        $modname = (strpos($item->name, ':') !== false) ? explode(':', $item->name)[0] : $item->name;
+
+        return new content_item($item->id, $item->name, $item->title, $item->link, $item->icon, $item->help ?? '',
+            $item->archetype, 'mod_' . $modname);
+    }
+
+    /**
+     * Create a stdClass type object based on a content_item instance.
+     *
+     * @param content_item $contentitem
+     * @return \stdClass the legacy data.
+     */
+    private function content_item_to_legacy_data(content_item $contentitem): \stdClass {
+        $item = new \stdClass();
+        $item->id = $contentitem->get_id();
+        $item->name = $contentitem->get_name();
+        $item->title = $contentitem->get_title();
+        $item->link = $contentitem->get_link();
+        $item->icon = $contentitem->get_icon();
+        $item->help = $contentitem->get_help();
+        $item->archetype = $contentitem->get_archetype();
+        $item->componentname = $contentitem->get_component_name();
+        return $item;
+    }
+
+    /**
+     * Helper to get the contentitems from all subplugin hooks for a given module plugin.
+     *
+     * @param string $parentpluginname the name of the module plugin to check subplugins for.
+     * @param content_item $modulecontentitem the content item of the module plugin, to pass to the hooks.
+     * @param \stdClass $user the user object to pass to subplugins.
+     * @return array the array of content items.
+     */
+    private function get_subplugin_course_content_items(string $parentpluginname, content_item $modulecontentitem,
+            \stdClass $user): array {
+
+        $contentitems = [];
+        $pluginmanager = \core_plugin_manager::instance();
+        foreach ($pluginmanager->get_subplugins_of_plugin($parentpluginname) as $subpluginname => $subplugin) {
+            // Call the hook, but with a copy of the module content item data.
+            $spcontentitems = component_callback($subpluginname, 'get_course_content_items', [$modulecontentitem, $user], null);
+            if (!is_null($spcontentitems)) {
+                foreach ($spcontentitems as $spcontentitem) {
+                    $contentitems[] = $spcontentitem;
+                }
+            }
+        }
+        return $contentitems;
+    }
+
+    /**
+     * Get all the content items for a subplugin.
+     *
+     * @param string $parentpluginname
+     * @param content_item $modulecontentitem
+     * @return array
+     */
+    private function get_subplugin_all_content_items(string $parentpluginname, content_item $modulecontentitem): array {
+        $contentitems = [];
+        $pluginmanager = \core_plugin_manager::instance();
+        foreach ($pluginmanager->get_subplugins_of_plugin($parentpluginname) as $subpluginname => $subplugin) {
+            // Call the hook, but with a copy of the module content item data.
+            $spcontentitems = component_callback($subpluginname, 'get_all_content_items', [$modulecontentitem], null);
+            if (!is_null($spcontentitems)) {
+                foreach ($spcontentitems as $spcontentitem) {
+                    $contentitems[] = $spcontentitem;
+                }
+            }
+        }
+        return $contentitems;
+    }
+
+    /**
+     * Helper to make sure any legacy items have certain properties, which, if missing are inherited from the parent module item.
+     *
+     * @param \stdClass $legacyitem the legacy information, a stdClass coming from get_shortcuts() hook.
+     * @param content_item $modulecontentitem The module's content item information, to inherit if needed.
+     * @return \stdClass the updated legacy item stdClass
+     */
+    private function legacy_item_inherit_missing(\stdClass $legacyitem, content_item $modulecontentitem): \stdClass {
+        // Fall back to the plugin parent value if the subtype didn't provide anything.
+        $legacyitem->archetype = $legacyitem->archetype ?? $modulecontentitem->get_archetype();
+        $legacyitem->icon = $legacyitem->icon ?? $modulecontentitem->get_icon();
+        return $legacyitem;
+    }
+
+    /**
+     * Find all the available content items, not restricted to course or user.
+     *
+     * @return array the array of content items.
+     */
+    public function find_all(): array {
+        global $OUTPUT, $DB;
+
+        // Get all modules so we know which plugins are enabled and able to add content.
+        // Only module plugins may add content items.
+        $modules = $DB->get_records('modules', ['visible' => 1]);
+        $return = [];
+
+        // Now, generate the content_items.
+        foreach ($modules as $modid => $mod) {
+            // Create the content item for the module itself.
+            // If the module chooses to implement the hook, this may be thrown away.
+            $help = $this->get_core_module_help_string($mod->name);
+            $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
+
+            $contentitem = new content_item(
+                $mod->id,
+                $mod->name,
+                new lang_string_title("modulename", $mod->name),
+                new \moodle_url(''), // No course scope, so just an empty link.
+                $OUTPUT->pix_icon('icon', '', $mod->name, ['class' => 'icon']),
+                $help,
+                $archetype,
+                'mod_' . $mod->name
+            );
+
+            $modcontentitemreference = clone($contentitem);
+
+            if (component_callback_exists('mod_' . $mod->name, 'get_all_content_items')) {
+                // Call the module hooks for this module.
+                $plugincontentitems = component_callback('mod_' . $mod->name, 'get_all_content_items',
+                    [$modcontentitemreference], []);
+                if (!empty($plugincontentitems)) {
+                    array_push($return, ...$plugincontentitems);
+                }
+
+                // Now, get those for subplugins of the module.
+                $subplugincontentitems = $this->get_subplugin_all_content_items('mod_' . $mod->name, $modcontentitemreference);
+                if (!empty($subplugincontentitems)) {
+                    array_push($return, ...$subplugincontentitems);
+                }
+            } else {
+                // Neither callback was found, so just use the default module content item.
+                $return[] = $contentitem;
+            }
+        }
+        return $return;
+    }
+
+    /**
+     * Get the list of potential content items for the given course.
+     *
+     * @param \stdClass $course the course
+     * @param \stdClass $user the user, to pass to plugins implementing callbacks.
+     * @return array the array of content_item objects
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array {
+        global $OUTPUT, $DB;
+
+        // Get all modules so we know which plugins are enabled and able to add content.
+        // Only module plugins may add content items.
+        $modules = $DB->get_records('modules', ['visible' => 1]);
+        $return = [];
+
+        // A moodle_url is expected and required by modules in their implementation of the hook 'get_shortcuts'.
+        $urlbase = new \moodle_url('/course/mod.php', ['id' => $course->id]);
+
+        // Now, generate the content_items.
+        foreach ($modules as $modid => $mod) {
+
+            // Create the content item for the module itself.
+            // If the module chooses to implement the hook, this may be thrown away.
+            $help = $this->get_core_module_help_string($mod->name);
+            $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
+
+            $contentitem = new content_item(
+                $mod->id,
+                $mod->name,
+                new lang_string_title("modulename", $mod->name),
+                new \moodle_url($urlbase, ['add' => $mod->name]),
+                $OUTPUT->pix_icon('icon', '', $mod->name, ['class' => 'icon']),
+                $help,
+                $archetype,
+                'mod_' . $mod->name
+            );
+
+            // Legacy vs new hooks.
+            // If the new hook is found for a module plugin, use that path (calling mod plugins and their subplugins directly)
+            // If not, check the legacy hook. This won't provide us with enough information to identify items uniquely within their
+            // component (lti + lti source being an example), but we can still list these items.
+            $modcontentitemreference = clone($contentitem);
+
+            if (component_callback_exists('mod_' . $mod->name, 'get_course_content_items')) {
+                // Call the module hooks for this module.
+                $plugincontentitems = component_callback('mod_' . $mod->name, 'get_course_content_items',
+                    [$modcontentitemreference, $user, $course], []);
+                if (!empty($plugincontentitems)) {
+                    array_push($return, ...$plugincontentitems);
+                }
+
+                // Now, get those for subplugins of the module.
+                $subpluginitems = $this->get_subplugin_course_content_items('mod_' . $mod->name, $modcontentitemreference, $user);
+                if (!empty($subpluginitems)) {
+                    array_push($return, ...$subpluginitems);
+                }
+
+            } else if (component_callback_exists('mod_' . $mod->name, 'get_shortcuts')) {
+                // TODO: MDL-68011 this block needs to be removed in 4.3.
+                debugging('The callback get_shortcuts has been deprecated. Please use get_course_content_items and
+                    get_all_content_items instead. Some features of the activity chooser, such as favourites and recommendations
+                    are not supported when providing content items via the deprecated callback.');
+
+                // If get_shortcuts() callback is defined, the default module action is not added.
+                // It is a responsibility of the callback to add it to the return value unless it is not needed.
+                // The legacy hook, get_shortcuts, expects a stdClass representation of the core module content_item entry.
+                $modcontentitemreference = $this->content_item_to_legacy_data($contentitem);
+
+                $legacyitems = component_callback($mod->name, 'get_shortcuts', [$modcontentitemreference], null);
+                if (!is_null($legacyitems)) {
+                    foreach ($legacyitems as $legacyitem) {
+
+                        $legacyitem = $this->legacy_item_inherit_missing($legacyitem, $contentitem);
+
+                        // All items must have different links, use them as a key in the return array.
+                        // If plugin returned the only one item with the same link as default item - keep $modname,
+                        // otherwise append the link url to the module name.
+                        $legacyitem->name = (count($legacyitems) == 1 &&
+                            $legacyitem->link->out() === $contentitem->get_link()->out()) ? $mod->name : $mod->name . ':' .
+                                $legacyitem->link;
+
+                        $plugincontentitem = $this->content_item_from_legacy_data($legacyitem);
+
+                        $return[] = $plugincontentitem;
+                    }
+                }
+            } else {
+                // Neither callback was found, so just use the default module content item.
+                $return[] = $contentitem;
+            }
+        }
+
+        return $return;
+    }
+}
diff --git a/course/classes/local/repository/content_item_readonly_repository_interface.php b/course/classes/local/repository/content_item_readonly_repository_interface.php
new file mode 100644 (file)
index 0000000..d33e85b
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the interface content_item_readonly_repository_interface, defining operations for readonly content item repositories.
+ *
+ * This interface is not considered a published interface and serves to govern internal, local repository objects only.
+ * All calling code should use instances of the service classes, and should not interact with repositories directly.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\repository;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface content_item_readonly_repository_interface {
+    /**
+     * Find all content items for a given course and user.
+     *
+     * @param \stdClass $course the course object.
+     * @param \stdClass $user the user object.
+     * @return array the array of content items.
+     */
+    public function find_all_for_course(\stdClass $course, \stdClass $user): array;
+
+    /**
+     * Find all content items that can be presented, irrespective of course.
+     *
+     * @return array the array of content items.
+     */
+    public function find_all(): array;
+}
diff --git a/course/classes/local/service/content_item_service.php b/course/classes/local/service/content_item_service.php
new file mode 100644 (file)
index 0000000..3c2a605
--- /dev/null
@@ -0,0 +1,340 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the content_item_service class.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_course\local\service;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_course\local\exporters\course_content_items_exporter;
+use core_course\local\repository\content_item_readonly_repository_interface;
+
+/**
+ * The content_item_service class, providing the api for interacting with content items.
+ *
+ * @copyright  2020 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_item_service {
+
+    /** @var content_item_readonly_repository_interface $repository a repository for content items. */
+    private $repository;
+
+    /** string the component for this favourite. */
+    public const COMPONENT = 'core_course';
+    /** string the favourite prefix itemtype in the favourites table. */
+    public const FAVOURITE_PREFIX = 'contentitem_';
+    /** string the recommendation prefix itemtype in the favourites table. */
+    public const RECOMMENDATION_PREFIX = 'recommend_';
+    /** string the cache name for recommendations. */
+    public const RECOMMENDATION_CACHE = 'recommendation_favourite_course_content_items';
+
+    /**
+     * The content_item_service constructor.
+     *
+     * @param content_item_readonly_repository_interface $repository a content item repository.
+     */
+    public function __construct(content_item_readonly_repository_interface $repository) {
+        $this->repository = $repository;
+    }
+
+    /**
+     * Returns an array of objects representing favourited content items.
+     *
+     * Each object contains the following properties:
+     * itemtype: a string containing the 'itemtype' key used by the favourites subsystem.
+     * ids[]: an array of ids, representing the content items within a component.
+     *
+     * Since two components can return (via their hook implementation) the same id, the itemtype is used for uniqueness.
+     *
+     * @param \stdClass $user
+     * @return array
+     */
+    private function get_favourite_content_items_for_user(\stdClass $user): array {
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $key = $user->id;
+        $favmods = $favcache->get($key);
+        if ($favmods !== false) {
+            return $favmods;
+        }
+
+        $favourites = $this->get_content_favourites(self::FAVOURITE_PREFIX, \context_user::instance($user->id));
+
+        $favcache->set($key, $favourites);
+        return $favourites;
+    }
+
+    /**
+     * Returns an array of objects representing recommended content items.
+     *
+     * Each object contains the following properties:
+     * itemtype: a string containing the 'itemtype' key used by the favourites subsystem.
+     * ids[]: an array of ids, representing the content items within a component.
+     *
+     * Since two components can return (via their hook implementation) the same id, the itemtype is used for uniqueness.
+     *
+     * @return array
+     */
+    private function get_recommendations(): array {
+        global $CFG;
+
+        $recommendationcache = \cache::make('core', self::RECOMMENDATION_CACHE);
+        $key = $CFG->siteguest;
+        $favmods = $recommendationcache->get($key);
+        if ($favmods !== false) {
+            return $favmods;
+        }
+
+        $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, \context_user::instance($CFG->siteguest));
+
+        $recommendationcache->set($CFG->siteguest, $favourites);
+        return $favourites;
+    }
+
+    /**
+     * Gets content favourites from the favourites system depending on the area.
+     *
+     * @param  string        $prefix      Prefix for the item type.
+     * @param  \context_user $usercontext User context for the favourite
+     * @return array An array of favourite objects.
+     */
+    private function get_content_favourites(string $prefix, \context_user $usercontext): array {
+        // Get all modules and any submodules which implement get_course_content_items() hook.
+        // This gives us the set of all itemtypes which we'll use to register favourite content items.
+        // The ids that each plugin returns will be used together with the itemtype to uniquely identify
+        // each content item for favouriting.
+        $pluginmanager = \core_plugin_manager::instance();
+        $plugins = $pluginmanager->get_plugins_of_type('mod');
+        $itemtypes = [];
+        foreach ($plugins as $plugin) {
+            // Add the mod itself.
+            $itemtypes[] = $prefix . 'mod_' . $plugin->name;
+
+            // Add any subplugins to the list of item types.
+            $subplugins = $pluginmanager->get_subplugins_of_plugin('mod_' . $plugin->name);
+            foreach ($subplugins as $subpluginname => $subplugininfo) {
+                if (component_callback_exists($subpluginname, 'get_course_content_items')) {
+                    $itemtypes[] = $prefix . $subpluginname;
+                }
+            }
+        }
+
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        $favourites = [];
+        foreach ($itemtypes as $itemtype) {
+            $favs = $ufservice->find_favourites_by_type(self::COMPONENT, $itemtype);
+            $favobj = (object) ['itemtype' => $itemtype, 'ids' => array_column($favs, 'itemid')];
+            $favourites[] = $favobj;
+        }
+        return $favourites;
+    }
+
+    /**
+     * Get all content items which may be added to courses, irrespective of course caps, for site admin views, etc.
+     *
+     * @param \stdClass $user the user object.
+     * @return array the array of exported content items.
+     */
+    public function get_all_content_items(\stdClass $user): array {
+        global $PAGE;
+        $allcontentitems = $this->repository->find_all();
+
+        // Export the objects to get the formatted objects for transfer/display.
+        $favourites = $this->get_favourite_content_items_for_user($user);
+        $recommendations = $this->get_recommendations();
+        $ciexporter = new course_content_items_exporter(
+            $allcontentitems,
+            [
+                'context' => \context_system::instance(),
+                'favouriteitems' => $favourites,
+                'recommended' => $recommendations
+            ]
+        );
+        $exported = $ciexporter->export($PAGE->get_renderer('core'));
+
+        // Sort by title for return.
+        usort($exported->content_items, function($a, $b) {
+            return $a->title > $b->title;
+        });
+
+        return $exported->content_items;
+    }
+
+    /**
+     * Return a representation of the available content items, for a user in a course.
+     *
+     * @param \stdClass $user the user to check access for.
+     * @param \stdClass $course the course to scope the content items to.
+     * @param array $linkparams the desired section to return to.
+     * @return \stdClass[] the content items, scoped to a course.
+     */
+    public function get_content_items_for_user_in_course(\stdClass $user, \stdClass $course, array $linkparams = []): array {
+        global $PAGE;
+
+        if (!has_capability('moodle/course:manageactivities', \context_course::instance($course->id), $user)) {
+            return [];
+        }
+
+        // Get all the visible content items.
+        $allcontentitems = $this->repository->find_all_for_course($course, $user);
+
+        // Content items can only originate from modules or submodules.
+        $pluginmanager = \core_plugin_manager::instance();
+        $components = \core_component::get_component_list();
+        $parents = [];
+        foreach ($allcontentitems as $contentitem) {
+            if (!in_array($contentitem->get_component_name(), array_keys($components['mod']))) {
+                // It could be a subplugin.
+                $info = $pluginmanager->get_plugin_info($contentitem->get_component_name());
+                if (!is_null($info)) {
+                    $parent = $info->get_parent_plugin();
+                    if ($parent != false) {
+                        if (in_array($parent, array_keys($components['mod']))) {
+                            $parents[$contentitem->get_component_name()] = $parent;
+                            continue;
+                        }
+                    }
+                }
+                throw new \moodle_exception('Only modules and submodules can generate content items. \''
+                    . $contentitem->get_component_name() . '\' is neither.');
+            }
+            $parents[$contentitem->get_component_name()] = $contentitem->get_component_name();
+        }
+
+        // Now, check access to these items for the user.
+        $availablecontentitems = array_filter($allcontentitems, function($contentitem) use ($course, $user, $parents) {
+            // Check the parent module access for the user.
+            return course_allowed_module($course, explode('_', $parents[$contentitem->get_component_name()])[1], $user);
+        });
+
+        // Add the link params to the link, if any have been provided.
+        if (!empty($linkparams)) {
+            $availablecontentitems = array_map(function ($item) use ($linkparams) {
+                $item->get_link()->params($linkparams);
+                return $item;
+            }, $availablecontentitems);
+        }
+
+        // Export the objects to get the formatted objects for transfer/display.
+        $favourites = $this->get_favourite_content_items_for_user($user);
+        $recommended = $this->get_recommendations();
+        $ciexporter = new course_content_items_exporter(
+            $availablecontentitems,
+            [
+                'context' => \context_course::instance($course->id),
+                'favouriteitems' => $favourites,
+                'recommended' => $recommended
+            ]
+        );
+        $exported = $ciexporter->export($PAGE->get_renderer('course'));
+
+        // Sort by title for return.
+        usort($exported->content_items, function($a, $b) {
+            return $a->title > $b->title;
+        });
+
+        return $exported->content_items;
+    }
+
+    /**
+     * Add a content item to a user's favourites.
+     *
+     * @param \stdClass $user the user whose favourite this is.
+     * @param string $componentname the name of the component from which the content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return \stdClass the exported content item.
+     */
+    public function add_to_user_favourites(\stdClass $user, string $componentname, int $contentitemid): \stdClass {
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // Because each plugin decides its own ids for content items, a combination of
+        // itemtype and id is used to guarantee uniqueness across all content items.
+        $itemtype = self::FAVOURITE_PREFIX . $componentname;
+
+        $ufservice->create_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
+
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $favcache->delete($user->id);
+
+        $items = $this->get_all_content_items($user);
+        return $items[array_search($contentitemid, array_column($items, 'id'))];
+    }
+
+    /**
+     * Remove the content item from a user's favourites.
+     *
+     * @param \stdClass $user the user whose favourite this is.
+     * @param string $componentname the name of the component from which the content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return \stdClass the exported content item.
+     */
+    public function remove_from_user_favourites(\stdClass $user, string $componentname, int $contentitemid): \stdClass {
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // Because each plugin decides its own ids for content items, a combination of
+        // itemtype and id is used to guarantee uniqueness across all content items.
+        $itemtype = self::FAVOURITE_PREFIX . $componentname;
+
+        $ufservice->delete_favourite(self::COMPONENT, $itemtype, $contentitemid, $usercontext);
+
+        $favcache = \cache::make('core', 'user_favourite_course_content_items');
+        $favcache->delete($user->id);
+
+        $items = $this->get_all_content_items($user);
+        return $items[array_search($contentitemid, array_column($items, 'id'))];
+    }
+
+    /**
+     * Toggle an activity to being recommended or not.
+     *
+     * @param  string $itemtype The component such as mod_assign, or assignsubmission_file
+     * @param  int    $itemid   The id related to this component item.
+     * @return bool True on creating a favourite, false on deleting it.
+     */
+    public function toggle_recommendation(string $itemtype, int $itemid): bool {
+        global $CFG;
+
+        $context = \context_system::instance();
+
+        $itemtype = self::RECOMMENDATION_PREFIX . $itemtype;
+
+        // Favourites are created using a user context. We'll use the site guest user ID as that should not change and there
+        // can be only one.
+        $usercontext = \context_user::instance($CFG->siteguest);
+
+        $recommendationcache = \cache::make('core', self::RECOMMENDATION_CACHE);
+
+        $favouritefactory = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        if ($favouritefactory->favourite_exists(self::COMPONENT, $itemtype, $itemid, $context)) {
+            $favouritefactory->delete_favourite(self::COMPONENT, $itemtype, $itemid, $context);
+            $result = $recommendationcache->delete($CFG->siteguest);
+            return false;
+        } else {
+            $favouritefactory->create_favourite(self::COMPONENT, $itemtype, $itemid, $context);
+            $result = $recommendationcache->delete($CFG->siteguest);
+            return true;
+        }
+    }
+}
diff --git a/course/classes/output/modchooser.php b/course/classes/output/modchooser.php
deleted file mode 100644 (file)
index 95e487c..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * The modchooser renderable.
- *
- * @package    core_course
- * @copyright  2016 Frédéric Massart - FMCorz.net
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-namespace core_course\output;
-defined('MOODLE_INTERNAL') || die();
-
-use core\output\chooser;
-use core\output\chooser_section;
-use context_course;
-use lang_string;
-use moodle_url;
-use pix_icon;
-use renderer_base;
-use stdClass;
-
-/**
- * The modchooser renderable class.
- *
- * @package    core_course
- * @copyright  2016 Frédéric Massart - FMCorz.net
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class modchooser extends chooser {
-
-    /** @var stdClass The course. */
-    public $course;
-
-    /**
-     * Constructor.
-     *
-     * @param stdClass $course The course.
-     * @param stdClass[] $modules The modules.
-     */
-    public function __construct(stdClass $course, array $modules) {
-        $this->course = $course;
-
-        $sections = [];
-        $context = context_course::instance($course->id);
-
-         // Activities.
-        $activities = array_filter($modules, function($mod) {
-            return ($mod->archetype !== MOD_ARCHETYPE_RESOURCE && $mod->archetype !== MOD_ARCHETYPE_SYSTEM);
-        });
-        if (count($activities)) {
-            $sections[] = new chooser_section('activities', new lang_string('activities'),
-                array_map(function($module) use ($context) {
-                    return new modchooser_item($module, $context);
-                }, $activities)
-            );
-        }
-
-        $resources = array_filter($modules, function($mod) {
-            return ($mod->archetype === MOD_ARCHETYPE_RESOURCE);
-        });
-        if (count($resources)) {
-            $sections[] = new chooser_section('resources', new lang_string('resources'),
-                array_map(function($module) use ($context) {
-                    return new modchooser_item($module, $context);
-                }, $resources)
-            );
-        }
-
-        $actionurl = new moodle_url('/course/jumpto.php');
-        $title = new lang_string('addresourceoractivity');
-        parent::__construct($actionurl, $title, $sections, 'jumplink');
-
-        $this->set_instructions(new lang_string('selectmoduletoviewhelp'));
-        $this->add_param('course', $course->id);
-    }
-
-    /**
-     * Export for template.
-     *
-     * @param renderer_base  The renderer.
-     * @return stdClass
-     */
-    public function export_for_template(renderer_base $output) {
-        $data = parent::export_for_template($output);
-        $data->courseid = $this->course->id;
-        return $data;
-    }
-
-}
diff --git a/course/classes/output/modchooser_item.php b/course/classes/output/modchooser_item.php
deleted file mode 100644 (file)
index 553e534..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * The modchooser_item renderable.
- *
- * @package    core_course
- * @copyright  2016 Frédéric Massart - FMCorz.net
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-namespace core_course\output;
-defined('MOODLE_INTERNAL') || die();
-
-use context;
-use lang_string;
-use pix_icon;
-
-/**
- * The modchooser_item renderable class.
- *
- * @package    core_course
- * @copyright  2016 Frédéric Massart - FMCorz.net
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class modchooser_item extends \core\output\chooser_item {
-
-    /** @var string */
-    protected $customiconurl;
-
-    /**
-     * Constructor.
-     *
-     * @param stdClass $module The module.
-     * @param context $context The relevant context.
-     */
-    public function __construct($module, context $context) {
-        // The property 'name' may contain more than just the module, in which case we need to extract the true module name.
-        $modulename = $module->name;
-        if ($colon = strpos($modulename, ':')) {
-            $modulename = substr($modulename, 0, $colon);
-        }
-        if (preg_match('/src="([^"]*)"/i', $module->icon, $matches)) {
-            // Use the custom icon.
-            $this->customiconurl = str_replace('&amp;', '&', $matches[1]);
-        }
-
-        $icon = new pix_icon('icon', '', $modulename, ['class' => 'icon']);
-        $help = isset($module->help) ? $module->help : new lang_string('nohelpforactivityorresource', 'moodle');
-
-        parent::__construct($module->name, $module->title, $module->link->out(false), $icon, $help, $context);
-    }
-
-    /**
-     * Export for template.
-     *
-     * @param \renderer_base $output The renderer
-     * @return \stdClass $data
-     */
-    public function export_for_template(\renderer_base $output) {
-        $data = parent::export_for_template($output);
-        if ($this->customiconurl && !empty($data->icon['attributes'])) {
-            // Replace icon source with a module-provided icon.
-            foreach ($data->icon['attributes'] as &$attribute) {
-                if ($attribute['name'] === 'src') {
-                    $attribute['value'] = $this->customiconurl;
-                }
-            }
-        }
-        return $data;
-    }
-}
diff --git a/course/classes/output/recommendations/activity_list.php b/course/classes/output/recommendations/activity_list.php
new file mode 100644 (file)
index 0000000..0352734
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains activity_list renderable used for the recommended activities page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output\recommendations;
+
+/**
+ * Activity list renderable.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class activity_list implements \renderable, \templatable {
+
+    /** @var array $modules activities to display in the recommendations page. */
+    protected $modules;
+
+    /**
+     * Constructor method.
+     *
+     * @param array $modules Activities to display
+     */
+    public function __construct(array $modules) {
+        $this->modules = $modules;
+    }
+
+    /**
+     * Export method to configure information into something the template can use.
+     *
+     * @param  \renderer_base $output Not actually used.
+     * @return array Template context information.
+     */
+    public function export_for_template(\renderer_base $output): array {
+
+        $info = array_map(function($module) {
+            return [
+                'id' => $module->id ?? '',
+                'name' => $module->title,
+                'componentname' => $module->componentname,
+                'icon' => $module->icon,
+                'recommended' => $module->recommended ?? ''
+            ];
+        }, $this->modules);
+
+        return ['categories' => ['categoryname' => get_string('activities'), 'categorydata' => $info]];
+    }
+}
diff --git a/course/classes/output/recommendations/renderer.php b/course/classes/output/recommendations/renderer.php
new file mode 100644 (file)
index 0000000..676d414
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains renderers for the recommendations page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output\recommendations;
+
+/**
+ * Main renderer for the recommendations page.
+ *
+ * @package core_course
+ * @copyright 2020 Adrian Greeve
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render a list of activities to recommend.
+     *
+     * @param  \core_course\output\recommendations\activity_list $page activity list renderable
+     * @return string html for displaying.
+     */
+    public function render_activity_list(\core_course\output\recommendations\activity_list $page): string {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('core_course/activity_list', $data);
+    }
+}
index edeb50c..fab27bc 100644 (file)
@@ -56,6 +56,7 @@ class provider implements
     public static function get_metadata(collection $collection) : collection {
         $collection->add_subsystem_link('core_completion', [], 'privacy:metadata:completionsummary');
         $collection->add_subsystem_link('core_favourites', [], 'privacy:metadata:favouritessummary');
+        $collection->add_subsystem_link('core_favourites', [], 'privacy:metadata:activityfavouritessummary');
         $collection->add_user_preference('coursecat_management_perpage', 'privacy:perpage');
         return $collection;
     }
index b39acfe..5ddf89e 100644 (file)
@@ -4140,4 +4140,200 @@ class core_course_external extends external_api {
         );
         return new external_single_structure($userfields);
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function add_content_item_to_user_favourites_parameters() {
+        return new external_function_parameters([
+            'componentname' => new external_value(PARAM_TEXT,
+                'frankenstyle name of the component to which the content item belongs', VALUE_REQUIRED),
+            'contentitemid' => new external_value(PARAM_INT, 'id of the content item', VALUE_REQUIRED, '', NULL_NOT_ALLOWED)
+        ]);
+    }
+
+    /**
+     * Add a content item to a user's favourites.
+     *
+     * @param string $componentname the name of the component from which this content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return stdClass the exporter content item.
+     */
+    public static function add_content_item_to_user_favourites(string $componentname, int $contentitemid) {
+        global $USER;
+
+        [
+            'componentname' => $componentname,
+            'contentitemid' => $contentitemid,
+        ] = self::validate_parameters(self::add_content_item_to_user_favourites_parameters(),
+            [
+                'componentname' => $componentname,
+                'contentitemid' => $contentitemid,
+            ]
+        );
+
+        self::validate_context(context_user::instance($USER->id));
+
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+
+        return $contentitemservice->add_to_user_favourites($USER, $componentname, $contentitemid);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function add_content_item_to_user_favourites_returns() {
+        return \core_course\local\exporters\course_content_item_exporter::get_read_structure();
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function remove_content_item_from_user_favourites_parameters() {
+        return new external_function_parameters([
+            'componentname' => new external_value(PARAM_TEXT,
+                'frankenstyle name of the component to which the content item belongs', VALUE_REQUIRED),
+            'contentitemid' => new external_value(PARAM_INT, 'id of the content item', VALUE_REQUIRED, '', NULL_NOT_ALLOWED),
+        ]);
+    }
+
+    /**
+     * Remove a content item from a user's favourites.
+     *
+     * @param string $componentname the name of the component from which this content item originates.
+     * @param int $contentitemid the id of the content item.
+     * @return stdClass the exported content item.
+     */
+    public static function remove_content_item_from_user_favourites(string $componentname, int $contentitemid) {
+        global $USER;
+
+        [
+            'componentname' => $componentname,
+            'contentitemid' => $contentitemid,
+        ] = self::validate_parameters(self::remove_content_item_from_user_favourites_parameters(),
+            [
+                'componentname' => $componentname,
+                'contentitemid' => $contentitemid,
+            ]
+        );
+
+        self::validate_context(context_user::instance($USER->id));
+
+        $contentitemservice = \core_course\local\factory\content_item_service_factory::get_content_item_service();
+
+        return $contentitemservice->remove_from_user_favourites($USER, $componentname, $contentitemid);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function remove_content_item_from_user_favourites_returns() {
+  &n