Merge branch 'MDL-65081-master' of git://github.com/junpataleta/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 26 Mar 2019 21:38:06 +0000 (22:38 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 26 Mar 2019 21:38:06 +0000 (22:38 +0100)
164 files changed:
admin/tool/mobile/classes/external.php
admin/tool/mobile/db/services.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/lib.php
admin/tool/mobile/tests/api_test.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/mobile/upgrade.txt
admin/tool/mobile/version.php
course/externallib.php
course/format/social/format.php
course/format/social/tests/behat/social_adjust_discussion_count.feature
course/renderer.php
course/tests/behat/course_creation.feature
files/classes/external/stored_file_exporter.php
lib/classes/external/exporter.php
lib/classes/external/paged_content_exporter.php [new file with mode: 0644]
lib/classes/message/manager.php
lib/classes/output/notification.php
lib/dml/moodle_database.php
lib/dml/tests/dml_test.php
lib/externallib.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/form/tests/behat/modgrade_validation.feature
lib/portfolio/constants.php
lib/portfoliolib.php
lib/templates/notification.mustache [new file with mode: 0644]
lib/tests/behat/locking.feature
lib/tests/exporter_test.php
lib/tests/externallib_test.php
lib/tests/filelib_test.php
lib/upgrade.txt
lib/upgradelib.php
mod/forum/amd/build/discussion_list.min.js [new file with mode: 0644]
mod/forum/amd/build/repository.min.js [new file with mode: 0644]
mod/forum/amd/build/selectors.min.js [new file with mode: 0644]
mod/forum/amd/build/subscription_toggle.min.js [new file with mode: 0644]
mod/forum/amd/src/discussion_list.js [new file with mode: 0644]
mod/forum/amd/src/repository.js [new file with mode: 0644]
mod/forum/amd/src/selectors.js [new file with mode: 0644]
mod/forum/amd/src/subscription_toggle.js [new file with mode: 0644]
mod/forum/classes/local/builders/exported_discussion_summaries.php [new file with mode: 0644]
mod/forum/classes/local/builders/exported_posts.php [new file with mode: 0644]
mod/forum/classes/local/container.php [new file with mode: 0644]
mod/forum/classes/local/data_mappers/legacy/author.php [new file with mode: 0644]
mod/forum/classes/local/data_mappers/legacy/discussion.php [new file with mode: 0644]
mod/forum/classes/local/data_mappers/legacy/forum.php [new file with mode: 0644]
mod/forum/classes/local/data_mappers/legacy/post.php [new file with mode: 0644]
mod/forum/classes/local/entities/author.php [new file with mode: 0644]
mod/forum/classes/local/entities/discussion.php [new file with mode: 0644]
mod/forum/classes/local/entities/discussion_summary.php [new file with mode: 0644]
mod/forum/classes/local/entities/forum.php [new file with mode: 0644]
mod/forum/classes/local/entities/post.php [new file with mode: 0644]
mod/forum/classes/local/entities/post_read_receipt_collection.php [new file with mode: 0644]
mod/forum/classes/local/entities/sorter.php [new file with mode: 0644]
mod/forum/classes/local/exporters/author.php [new file with mode: 0644]
mod/forum/classes/local/exporters/discussion.php [new file with mode: 0644]
mod/forum/classes/local/exporters/discussion_summaries.php [new file with mode: 0644]
mod/forum/classes/local/exporters/discussion_summary.php [new file with mode: 0644]
mod/forum/classes/local/exporters/forum.php [new file with mode: 0644]
mod/forum/classes/local/exporters/post.php [new file with mode: 0644]
mod/forum/classes/local/exporters/posts.php [new file with mode: 0644]
mod/forum/classes/local/factories/builder.php [new file with mode: 0644]
mod/forum/classes/local/factories/entity.php [new file with mode: 0644]
mod/forum/classes/local/factories/exporter.php [new file with mode: 0644]
mod/forum/classes/local/factories/legacy_data_mapper.php [new file with mode: 0644]
mod/forum/classes/local/factories/manager.php [new file with mode: 0644]
mod/forum/classes/local/factories/renderer.php [new file with mode: 0644]
mod/forum/classes/local/factories/url.php [new file with mode: 0644]
mod/forum/classes/local/factories/vault.php [new file with mode: 0644]
mod/forum/classes/local/managers/capability.php [new file with mode: 0644]
mod/forum/classes/local/renderers/discussion.php [new file with mode: 0644]
mod/forum/classes/local/renderers/discussion_list.php [new file with mode: 0644]
mod/forum/classes/local/renderers/posts.php [new file with mode: 0644]
mod/forum/classes/local/vaults/author.php [new file with mode: 0644]
mod/forum/classes/local/vaults/db_table_vault.php [new file with mode: 0644]
mod/forum/classes/local/vaults/discussion.php [new file with mode: 0644]
mod/forum/classes/local/vaults/discussion_list.php [new file with mode: 0644]
mod/forum/classes/local/vaults/forum.php [new file with mode: 0644]
mod/forum/classes/local/vaults/post.php [new file with mode: 0644]
mod/forum/classes/local/vaults/post_attachment.php [new file with mode: 0644]
mod/forum/classes/local/vaults/post_read_receipt_collection.php [new file with mode: 0644]
mod/forum/classes/local/vaults/preprocessors/extract_context.php [new file with mode: 0644]
mod/forum/classes/local/vaults/preprocessors/extract_record.php [new file with mode: 0644]
mod/forum/classes/local/vaults/preprocessors/extract_user.php [new file with mode: 0644]
mod/forum/db/caches.php [new file with mode: 0644]
mod/forum/db/services.php
mod/forum/deprecatedlib.php
mod/forum/discuss.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/markposts.php
mod/forum/post.php
mod/forum/search.php
mod/forum/settings.php
mod/forum/templates/blog_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/discussion_subscription_toggle.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_nested_post.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_nested_posts.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_post.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_posts.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_threaded_post.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_threaded_posts.mustache [new file with mode: 0644]
mod/forum/templates/forum_posts_with_context_links.mustache [new file with mode: 0644]
mod/forum/templates/frontpage_news_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/frontpage_social_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/news_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/qanda_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/single_discussion_list.mustache [new file with mode: 0644]
mod/forum/templates/social_discussion_list.mustache [new file with mode: 0644]
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_display.feature
mod/forum/tests/behat/edit_tags.feature
mod/forum/tests/behat/forum_subscriptions_default.feature
mod/forum/tests/behat/groups_in_course_no_groups_in_forum.feature
mod/forum/tests/behat/no_groups_in_course.feature
mod/forum/tests/behat/post_to_multiple_groups.feature
mod/forum/tests/behat/separate_group_discussions.feature
mod/forum/tests/behat/separate_group_single_group_discussions.feature
mod/forum/tests/behat/visible_group_discussions.feature
mod/forum/tests/builders_exported_posts_test.php [new file with mode: 0644]
mod/forum/tests/coverage.php [new file with mode: 0644]
mod/forum/tests/entities_author_test.php [new file with mode: 0644]
mod/forum/tests/entities_discussion_summary_test.php [new file with mode: 0644]
mod/forum/tests/entities_discussion_test.php [new file with mode: 0644]
mod/forum/tests/entities_forum_test.php [new file with mode: 0644]
mod/forum/tests/entities_post_read_receipt_collection_test.php [new file with mode: 0644]
mod/forum/tests/entities_post_test.php [new file with mode: 0644]
mod/forum/tests/entities_sorter_test.php [new file with mode: 0644]
mod/forum/tests/exporters_author_test.php [new file with mode: 0644]
mod/forum/tests/exporters_discussion_test.php [new file with mode: 0644]
mod/forum/tests/exporters_forum_test.php [new file with mode: 0644]
mod/forum/tests/exporters_post_test.php [new file with mode: 0644]
mod/forum/tests/generator/lib.php
mod/forum/tests/lib_test.php
mod/forum/tests/local_container_test.php [new file with mode: 0644]
mod/forum/tests/managers_capability_test.php [new file with mode: 0644]
mod/forum/tests/vaults_author_test.php [new file with mode: 0644]
mod/forum/tests/vaults_discussion_list_test.php [new file with mode: 0644]
mod/forum/tests/vaults_discussion_test.php [new file with mode: 0644]
mod/forum/tests/vaults_forum_test.php [new file with mode: 0644]
mod/forum/tests/vaults_post_attachment_test.php [new file with mode: 0644]
mod/forum/tests/vaults_post_read_receipt_collection_test.php [new file with mode: 0644]
mod/forum/tests/vaults_post_test.php [new file with mode: 0644]
mod/forum/upgrade.txt
mod/forum/user.php
mod/forum/version.php
mod/forum/view.php
mod/scorm/classes/external.php
mod/scorm/db/services.php
mod/scorm/tests/externallib_test.php
mod/scorm/version.php
mod/survey/lib.php
mod/survey/tests/lib_test.php
mod/wiki/lib.php
mod/wiki/tests/lib_test.php
mod/workshop/lib.php
mod/workshop/tests/lib_test.php
notes/externallib.php
notes/tests/externallib_test.php
notes/upgrade.txt [new file with mode: 0644]

index 6a46264..1795635 100644 (file)
@@ -26,6 +26,7 @@ namespace tool_mobile;
 defined('MOODLE_INTERNAL') || die();
 
 require_once("$CFG->libdir/externallib.php");
+require_once("$CFG->dirroot/webservice/lib.php");
 
 use external_api;
 use external_files;
@@ -460,4 +461,119 @@ class external extends external_api {
             )
         );
     }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.7
+     */
+    public static function call_external_functions_parameters() {
+        return new external_function_parameters([
+            'requests' => new external_multiple_structure(
+                new external_single_structure([
+                    'function' => new external_value(PARAM_ALPHANUMEXT, 'Function name'),
+                    'arguments' => new external_value(PARAM_RAW, 'JSON-encoded object with named arguments', VALUE_DEFAULT, '{}'),
+                    'settingraw' => new external_value(PARAM_BOOL, 'Return raw text', VALUE_DEFAULT, false),
+                    'settingfilter' => new external_value(PARAM_BOOL, 'Filter text', VALUE_DEFAULT, false),
+                    'settingfileurl' => new external_value(PARAM_BOOL, 'Rewrite plugin file URLs', VALUE_DEFAULT, true),
+                    'settinglang' => new external_value(PARAM_LANG, 'Session language', VALUE_DEFAULT, ''),
+                ])
+            )
+        ]);
+    }
+
+    /**
+     * Call multiple external functions and return all responses.
+     *
+     * @param array $requests List of requests.
+     * @return array Responses.
+     * @since Moodle 3.7
+     */
+    public static function call_external_functions($requests) {
+        global $SESSION;
+
+        $params = self::validate_parameters(self::call_external_functions_parameters(), ['requests' => $requests]);
+
+        // We need to check if the functions being called are included in the service of the current token.
+        // This function only works when using mobile services via REST (this is intended).
+        $webservicemanager = new \webservice;
+        $token = $webservicemanager->get_user_ws_token(required_param('wstoken', PARAM_ALPHANUM));
+
+        $settings = \external_settings::get_instance();
+        $defaultlang = current_language();
+        $responses = [];
+
+        foreach ($params['requests'] as $request) {
+            // Some external functions modify _GET or $_POST data, we need to restore the original data after each call.
+            $originalget = fullclone($_GET);
+            $originalpost = fullclone($_POST);
+
+            // Set external settings and language.
+            $settings->set_raw($request['settingraw']);
+            $settings->set_filter($request['settingfilter']);
+            $settings->set_fileurl($request['settingfileurl']);
+            $settings->set_lang($request['settinglang']);
+            $SESSION->lang = $request['settinglang'] ?: $defaultlang;
+
+            // Parse arguments to an array, validation is done in external_api::call_external_function.
+            $args = @json_decode($request['arguments'], true);
+            if (!is_array($args)) {
+                $args = [];
+            }
+
+            if ($webservicemanager->service_function_exists($request['function'], $token->externalserviceid)) {
+                $response = external_api::call_external_function($request['function'], $args, false);
+            } else {
+                // Function not included in the service, return an access exception.
+                $response = [
+                    'error' => true,
+                    'exception' => [
+                        'errorcode' => 'accessexception',
+                        'module' => 'webservice'
+                    ]
+                ];
+                if (debugging('', DEBUG_DEVELOPER)) {
+                    $response['exception']['debuginfo'] = 'Access to the function is not allowed.';
+                }
+            }
+
+            if (isset($response['data'])) {
+                $response['data'] = json_encode($response['data']);
+            }
+            if (isset($response['exception'])) {
+                $response['exception'] = json_encode($response['exception']);
+            }
+            $responses[] = $response;
+
+            // Restore original $_GET and $_POST.
+            $_GET = $originalget;
+            $_POST = $originalpost;
+
+            if ($response['error']) {
+                // Do not process the remaining requests.
+                break;
+            }
+        }
+
+        return ['responses' => $responses];
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_single_structure
+     * @since Moodle 3.7
+     */
+    public static function call_external_functions_returns() {
+        return new external_function_parameters([
+            'responses' => new external_multiple_structure(
+                new external_single_structure([
+                    'error' => new external_value(PARAM_BOOL, 'Whether an exception was thrown.'),
+                    'data' => new external_value(PARAM_RAW, 'JSON-encoded response data', VALUE_OPTIONAL),
+                    'exception' => new external_value(PARAM_RAW, 'JSON-encoed exception info', VALUE_OPTIONAL),
+                ])
+             )
+        ]);
+    }
 }
index 5e329c6..0b50d68 100644 (file)
@@ -61,6 +61,7 @@ $functions = array(
         'type'        => 'write',
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+
     'tool_mobile_get_content' => array(
         'classname'   => 'tool_mobile\external',
         'methodname'  => 'get_content',
@@ -68,5 +69,13 @@ $functions = array(
         'type'        => 'read',
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+
+    'tool_mobile_call_external_functions' => array(
+        'classname'   => 'tool_mobile\external',
+        'methodname'  => 'call_external_functions',
+        'description' => 'Call multiple external functions and return all responses.',
+        'type'        => 'write',
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
 );
 
index f2d08ac..a373878 100644 (file)
@@ -86,6 +86,7 @@ $string['mobilesettings'] = 'Mobile settings';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
 $string['pluginnotenabledorconfigured'] = 'Plugin not enabled or configured.';
+$string['readingthisemailgettheapp'] = 'Reading this in your e-mail? <a href="{$a}">Download the mobile app and receive notifications on your mobile devices</a>.';
 $string['remoteaddons'] = 'Remote add-ons';
 $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.';
 $string['setuplink'] = 'App download page';
index 0ecfeff..74b3c0b 100644 (file)
@@ -66,6 +66,12 @@ function tool_mobile_create_app_download_url() {
     }
 
     $downloadurl = new moodle_url($mobilesettings->setuplink);
+
+    // Do not update the URL if it is a custom one (we may break it completely).
+    if ($mobilesettings->setuplink != 'https://download.moodle.org/mobile') {
+        return $downloadurl;
+    }
+
     $downloadurl->param('version', $CFG->version);
     $downloadurl->param('lang', current_language());
 
@@ -80,6 +86,25 @@ function tool_mobile_create_app_download_url() {
     return $downloadurl;
 }
 
+/**
+ * Checks if the given user has a mobile token (has used recently the app).
+ *
+ * @param  int $userid the user to check
+ * @return bool        true if the user has a token, false otherwise.
+ */
+function tool_mobile_user_has_token($userid) {
+    global $DB;
+
+    $sql = "SELECT 1
+              FROM {external_tokens} t, {external_services} s
+             WHERE t.externalserviceid = s.id
+               AND s.enabled = 1
+               AND s.shortname IN ('moodle_mobile_app', 'local_mobile')
+               AND t.userid = ?";
+
+    return $DB->record_exists_sql($sql, [$userid]);
+}
+
 /**
  * User profile page callback.
  *
@@ -91,7 +116,7 @@ function tool_mobile_create_app_download_url() {
  * @return void Return if the mobile web services setting is disabled or if not the current user.
  */
 function tool_mobile_myprofile_navigation(\core_user\output\myprofile\tree $tree, $user, $iscurrentuser) {
-    global $CFG, $DB;
+    global $CFG;
 
     if (empty($CFG->enablemobilewebservice)) {
         return;
@@ -105,13 +130,7 @@ function tool_mobile_myprofile_navigation(\core_user\output\myprofile\tree $tree
         return;
     }
 
-    $sql = "SELECT 1
-              FROM {external_tokens} t, {external_services} s
-             WHERE t.externalserviceid = s.id
-               AND s.enabled = 1
-               AND s.shortname IN ('moodle_mobile_app', 'local_mobile')
-               AND t.userid = ?";
-    $userhastoken = $DB->record_exists_sql($sql, [$user->id]);
+    $userhastoken = tool_mobile_user_has_token($user->id);
 
     $mobilecategory = new core_user\output\myprofile\category('mobile', get_string('mobileapp', 'tool_mobile'),
             'loginactivity');
@@ -141,3 +160,42 @@ function tool_mobile_standard_footer_html() {
     }
     return $output;
 }
+
+/**
+ * Callback to be able to change a message/notification data per processor.
+ *
+ * @param  str $procname    processor name
+ * @param  stdClass $data   message or notification data
+ */
+function tool_mobile_pre_processor_message_send($procname, $data) {
+    global $CFG;
+
+    if (empty($CFG->enablemobilewebservice)) {
+        return;
+    }
+
+    if (empty($data->userto)) {
+        return;
+    }
+
+    // Only hack email.
+    if ($procname == 'email') {
+
+        // Send a message only when there is an HTML version of the email, mobile services are enabled,
+        // the user receiving the message has not used the app and there is an app download URL set.
+        if (empty($data->fullmessagehtml)) {
+            return;
+        }
+
+        if (!$url = tool_mobile_create_app_download_url()) {
+            return;
+        }
+
+        $userto = is_object($data->userto) ? $data->userto->id : $data->userto;
+        if (tool_mobile_user_has_token($userto)) {
+            return;
+        }
+
+        $data->fullmessagehtml .= html_writer::tag('p', get_string('readingthisemailgettheapp', 'tool_mobile', $url->out()));
+    }
+}
index f5ee434..4d9a07d 100644 (file)
@@ -94,4 +94,80 @@ class tool_mobile_api_testcase extends externallib_advanced_testcase {
             $this->assertTrue(in_array($issue[0], $expectedissues));
         }
     }
+
+    /**
+     * Test pre_processor_message_send callback.
+     */
+    public function test_pre_processor_message_send_callback() {
+        global $DB, $CFG;
+        require_once($CFG->libdir . '/externallib.php');
+        $this->preventResetByRollback();
+        $this->resetAfterTest();
+
+        // Enable mobile services and required configuration.
+        $CFG->enablewebservices = 1;
+        $CFG->enablemobilewebservice = 1;
+        $mobileappdownloadpage = 'htt://mobileappdownloadpage';
+        set_config('setuplink', $mobileappdownloadpage, 'tool_mobile');
+
+        $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1));
+        $user2 = $this->getDataGenerator()->create_user();
+        set_config('allowedemaildomains', 'example.com');
+
+        $DB->set_field_select('message_processors', 'enabled', 0, "name <> 'email'");
+        set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user2);
+
+        // Extra content for all types of messages.
+        $message = new \core\message\message();
+        $message->courseid          = 1;
+        $message->component         = 'moodle';
+        $message->name              = 'instantmessage';
+        $message->userfrom          = $user1;
+        $message->userto            = $user2;
+        $message->subject           = 'message subject 1';
+        $message->fullmessage       = 'message body';
+        $message->fullmessageformat = FORMAT_MARKDOWN;
+        $message->fullmessagehtml   = '<p>message body</p>';
+        $message->smallmessage      = 'small message';
+        $message->notification      = '0';
+        $content = array('*' => array('header' => ' test ', 'footer' => ' test '));
+        $message->set_additional_content('email', $content);
+
+        $sink = $this->redirectEmails();
+        $messageid = message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(1, $emails);
+        $email = reset($emails);
+
+        // Check we got the promotion text.
+        $this->assertContains($mobileappdownloadpage, quoted_printable_decode($email->body));
+        $sink->clear();
+
+        // Disable mobile so we don't get mobile promotions.
+        $CFG->enablemobilewebservice = 0;
+        $messageid = message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(1, $emails);
+        $email = reset($emails);
+        // Check we don't get the promotion text.
+        $this->assertNotContains($mobileappdownloadpage, quoted_printable_decode($email->body));
+        $sink->clear();
+
+        // Enable mobile again and set current user mobile token so we don't get mobile promotions.
+        $CFG->enablemobilewebservice = 1;
+        $user3 = $this->getDataGenerator()->create_user();
+        $this->setUser($user3);
+        $service = $DB->get_record('external_services', array('shortname' => MOODLE_OFFICIAL_MOBILE_SERVICE));
+        $token = external_generate_token_for_current_user($service);
+
+        $message->userto = $user3;
+        $messageid = message_send($message);
+        $emails = $sink->get_messages();
+        $this->assertCount(1, $emails);
+        $email = reset($emails);
+        // Check we don't get the promotion text.
+        $this->assertNotContains($mobileappdownloadpage, quoted_printable_decode($email->body));
+        $sink->clear();
+        $sink->close();
+    }
 }
index 00db97e..4045ab1 100644 (file)
@@ -30,6 +30,7 @@ global $CFG;
 
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
 require_once($CFG->dirroot . '/admin/tool/mobile/tests/fixtures/output/mobile.php');
+require_once($CFG->dirroot . '/webservice/lib.php');
 
 use tool_mobile\external;
 use tool_mobile\api;
@@ -358,4 +359,147 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $this->expectException('moodle_exception');
         $result = external::get_content('tool_blahblahblah', 'test_view');
     }
+
+    public function test_call_external_functions() {
+        global $SESSION;
+
+        $this->resetAfterTest(true);
+
+        $category = self::getDataGenerator()->create_category(array('name' => 'Category 1'));
+        $course = self::getDataGenerator()->create_course([
+            'category' => $category->id,
+            'shortname' => 'c1',
+            'summary' => '<span lang="en" class="multilang">Course summary</span>'
+                . '<span lang="eo" class="multilang">Kurso resumo</span>'
+                . '@@PLUGINFILE@@/filename.txt'
+                . '<!-- Comment stripped when formatting text -->',
+            'summaryformat' => FORMAT_MOODLE
+        ]);
+        $user1 = self::getDataGenerator()->create_user(['username' => 'user1', 'lastaccess' => time()]);
+        $user2 = self::getDataGenerator()->create_user(['username' => 'user2', 'lastaccess' => time()]);
+
+        self::setUser($user1);
+
+        // Setup WS token.
+        $webservicemanager = new \webservice;
+        $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE);
+        $token = external_generate_token_for_current_user($service);
+        $_POST['wstoken'] = $token->token;
+
+        // Workaround for external_api::call_external_function requiring sesskey.
+        $_POST['sesskey'] = sesskey();
+
+        // Call some functions.
+
+        $requests = [
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'id', 'value' => $course->id])
+            ],
+            [
+                'function' => 'core_user_get_users_by_field',
+                'arguments' => json_encode(['field' => 'id', 'values' => [$user1->id]])
+            ],
+            [
+                'function' => 'core_user_get_user_preferences',
+                'arguments' => json_encode(['name' => 'some_setting', 'userid' => $user2->id])
+            ],
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'shortname', 'value' => $course->shortname])
+            ],
+        ];
+        $result = external::call_external_functions($requests);
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(external::call_external_functions_returns(), $result);
+
+        // Only 3 responses, the 4th request is not executed because the 3rd throws an exception.
+        $this->assertCount(3, $result['responses']);
+
+        $this->assertFalse($result['responses'][0]['error']);
+        $coursedata = external_api::clean_returnvalue(
+            core_course_external::get_courses_by_field_returns(),
+            core_course_external::get_courses_by_field('id', $course->id));
+         $this->assertEquals(json_encode($coursedata), $result['responses'][0]['data']);
+
+        $this->assertFalse($result['responses'][1]['error']);
+        $userdata = external_api::clean_returnvalue(
+            core_user_external::get_users_by_field_returns(),
+            core_user_external::get_users_by_field('id', [$user1->id]));
+        $this->assertEquals(json_encode($userdata), $result['responses'][1]['data']);
+
+        $this->assertTrue($result['responses'][2]['error']);
+        $exception = json_decode($result['responses'][2]['exception'], true);
+        $this->assertEquals('nopermissions', $exception['errorcode']);
+
+        // Call a function not included in the external service.
+
+        $_POST['wstoken'] = $token->token;
+        $functions = $webservicemanager->get_not_associated_external_functions($service->id);
+        $requests = [['function' => current($functions)->name]];
+        $result = external::call_external_functions($requests);
+
+        $this->assertTrue($result['responses'][0]['error']);
+        $exception = json_decode($result['responses'][0]['exception'], true);
+        $this->assertEquals('accessexception', $exception['errorcode']);
+        $this->assertEquals('webservice', $exception['module']);
+
+        // Call a function with different external settings.
+
+        filter_set_global_state('multilang', TEXTFILTER_ON);
+        $_POST['wstoken'] = $token->token;
+        $SESSION->lang = 'eo'; // Change default language, so we can test changing it to "en".
+        $requests = [
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
+            ],
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
+                'settingraw' => '1'
+            ],
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
+                'settingraw' => '1',
+                'settingfileurl' => '0'
+            ],
+            [
+                'function' => 'core_course_get_courses_by_field',
+                'arguments' => json_encode(['field' => 'id', 'value' => $course->id]),
+                'settingfilter' => '1',
+                'settinglang' => 'en'
+            ],
+        ];
+        $result = external::call_external_functions($requests);
+
+        $this->assertCount(4, $result['responses']);
+
+        $context = \context_course::instance($course->id);
+        $pluginfile = 'webservice/pluginfile.php';
+
+        $this->assertFalse($result['responses'][0]['error']);
+        $data = json_decode($result['responses'][0]['data']);
+        $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
+        $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => false]);
+        $this->assertEquals($expected, $data->courses[0]->summary);
+
+        $this->assertFalse($result['responses'][1]['error']);
+        $data = json_decode($result['responses'][1]['data']);
+        $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
+        $this->assertEquals($expected, $data->courses[0]->summary);
+
+        $this->assertFalse($result['responses'][2]['error']);
+        $data = json_decode($result['responses'][2]['data']);
+        $this->assertEquals($course->summary, $data->courses[0]->summary);
+
+        $this->assertFalse($result['responses'][3]['error']);
+        $data = json_decode($result['responses'][3]['data']);
+        $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null);
+        $SESSION->lang = 'en'; // We expect filtered text in english.
+        $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => true]);
+        $this->assertEquals($expected, $data->courses[0]->summary);
+    }
 }
index 2149382..ec720c3 100644 (file)
@@ -1,6 +1,10 @@
 This files describes changes in tool_mobile code.
 Information provided here is intended especially for developers.
 
+=== 3.7 ===
+
+ * New external function tool_mobile::tool_mobile_call_external_function allows calling multiple external functions and returns all responses.
+
 === 3.5 ===
 
  * External function tool_mobile::tool_mobile_get_plugins_supporting_mobile now returns additional plugins information required by
index bc7dc20..766dfb2 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 defined('MOODLE_INTERNAL') || die();
-$plugin->version   = 2019021100; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019021101; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2018112800; // Requires this Moodle version.
 $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics).
 $plugin->dependencies = array(
index 3e8744c..a4d322c 100644 (file)
@@ -3539,6 +3539,17 @@ class core_course_external extends external_api {
         $id = $params['id'];
         $sectionreturn = $params['sectionreturn'];
 
+        // Set of permissions an editing user may have.
+        $contextarray = [
+                'moodle/course:update',
+                'moodle/course:manageactivities',
+                'moodle/course:activityvisibility',
+                'moodle/course:sectionvisibility',
+                'moodle/course:movesections',
+                'moodle/course:setcurrentsection',
+        ];
+        $PAGE->set_other_editing_capability($contextarray);
+
         list($course, $cm) = get_course_and_cm_from_cmid($id);
         $modcontext = context_module::instance($cm->id);
         $coursecontext = context_course::instance($course->id);
@@ -3646,6 +3657,17 @@ class core_course_external extends external_api {
         $id = $params['id'];
         $sectionreturn = $params['sectionreturn'];
 
+        // Set of permissions an editing user may have.
+        $contextarray = [
+            'moodle/course:update',
+            'moodle/course:manageactivities',
+            'moodle/course:activityvisibility',
+            'moodle/course:sectionvisibility',
+            'moodle/course:movesections',
+            'moodle/course:setcurrentsection',
+        ];
+        $PAGE->set_other_editing_capability($contextarray);
+
         // Validate access to the course (note, this is html for the course view page, we don't validate access to the module).
         list($course, $cm) = get_course_and_cm_from_cmid($id);
         self::validate_context(context_course::instance($course->id));
index 4fcd313..bde9709 100644 (file)
@@ -1,42 +1,78 @@
 <?php
-      // format.php - course format featuring social forum
-      //              included from view.php
-
-    require_once($CFG->dirroot.'/mod/forum/lib.php');
-
-    $strgroups  = get_string('groups');
-    $strgroupmy = get_string('groupmy');
-    $editing    = $PAGE->user_is_editing();
-
-    if ($forum = forum_get_course_forum($course->id, 'social')) {
-
-        $cm = get_coursemodule_from_instance('forum', $forum->id);
-        $modcontext = context_module::instance($cm->id);
-
-    /// Print forum intro above posts  MDL-18483
-        if (trim($forum->intro) != '') {
-            $options = new stdClass();
-            $options->para = false;
-            $introcontent = format_module_intro('forum', $forum, $cm->id);
-
-            if ($PAGE->user_is_editing() && has_capability('moodle/course:update', $modcontext)) {
-                $streditsummary  = get_string('editsummary');
-                $introcontent .= '<div class="editinglink"><a title="'.$streditsummary.'" '.
-                                 '   href="modedit.php?update='.$cm->id.'&amp;sesskey='.sesskey().'">'.
-                                 $OUTPUT->pix_icon('t/edit', $streditsummary) . '</a></div>';
-            }
-            echo $OUTPUT->box($introcontent, 'generalbox', 'intro');
-        }
-
-        echo '<div class="subscribelink">', forum_get_subscribe_link($forum, $modcontext), '</div>';
-
-        $numdiscussions = course_get_format($course)->get_course()->numdiscussions;
-        if ($numdiscussions < 1) {
-            // Make sure that the value is at least one.
-            $numdiscussions = 1;
-        }
-        forum_print_latest_discussions($course, $forum, $numdiscussions, 'plain', '', false);
-
-    } else {
-        echo $OUTPUT->notification('Could not find or create a social forum here');
+// 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/>.
+
+/**
+ * Course format featuring social forum.
+ *
+ * @package   format_social
+ * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$pageno = optional_param('p', 0, PARAM_INT);
+
+require_once($CFG->dirroot.'/mod/forum/lib.php');
+
+$forum = forum_get_course_forum($course->id, 'social');
+if (empty($forum)) {
+    echo $OUTPUT->notification('Could not find or create a social forum here');
+}
+
+$coursemodule = get_coursemodule_from_instance('forum', $forum->id);
+$modcontext = context_module::instance($coursemodule->id);
+
+$entityfactory = mod_forum\local\container::get_entity_factory();
+$forumentity = $entityfactory->get_forum_from_stdclass($forum, $modcontext, $coursemodule, $course);
+
+// Print forum intro above posts  MDL-18483.
+if (trim($forum->intro) != '') {
+    $options = (object) [
+        'para' => false,
+    ];
+    $introcontent = format_module_intro('forum', $forum, $coursemodule->id);
+
+    if ($PAGE->user_is_editing() && has_capability('moodle/course:update', $modcontext)) {
+        $streditsummary  = get_string('editsummary');
+        $introcontent .= html_writer::start_div('editinglink');
+        $introcontent .= html_writer::link(
+            new moodle_url('/modedit.php', [
+                'update' => $coursemodule->id,
+                'sesskey' => sesskey(),
+            ]),
+            $OUTPUT->pix_icon('t/edit', $streditsummary),
+            [
+                'title' => $streditsummary,
+            ]
+        );
+        $introcontent .= html_writer::end_div();
     }
+    echo $OUTPUT->box($introcontent, 'generalbox', 'intro');
+}
+
+echo html_writer::div(forum_get_subscribe_link($forum, $modcontext), 'subscribelink');
+
+$numdiscussions = course_get_format($course)->get_course()->numdiscussions;
+if ($numdiscussions < 1) {
+    // Make sure that the value is at least one.
+    $numdiscussions = 1;
+}
+
+$rendererfactory = mod_forum\local\container::get_renderer_factory();
+$discussionsrenderer = $rendererfactory->get_social_discussion_list_renderer($forumentity);
+$cm = \cm_info::create($coursemodule);
+echo $discussionsrenderer->render($USER, $cm, null, null, $pageno, $numdiscussions);
index f7216c7..830ad4e 100644 (file)
@@ -16,7 +16,7 @@ Feature: Change number of discussions displayed
       | teacher1 | C1 | editingteacher |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 10 |
       | Message | This is forum post ten |
@@ -24,7 +24,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 9 |
       | Message | This is forum post nine |
@@ -32,7 +32,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 8 |
       | Message | This is forum post eight |
@@ -40,7 +40,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 7 |
       | Message | This is forum post seven |
@@ -48,7 +48,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 6 |
       | Message | This is forum post six |
@@ -56,7 +56,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 5 |
       | Message | This is forum post five |
@@ -64,7 +64,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 4 |
       | Message | This is forum post four |
@@ -72,7 +72,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 3 |
       | Message | This is forum post three |
@@ -80,7 +80,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 2 |
       | Message | This is forum post two |
@@ -88,7 +88,7 @@ Feature: Change number of discussions displayed
     And I wait to be redirected
     And I am on "Course 1" course homepage
     And I wait "1" seconds
-    And I press "Add a new discussion topic"
+    And I follow "Add a new discussion topic"
     And I set the following fields to these values:
       | Subject | Forum Post 1 |
       | Message | This is forum post one |
index b7a01bd..186ff5c 100644 (file)
@@ -644,7 +644,7 @@ class core_course_renderer extends plugin_renderer_base {
 
         // Display link itself.
         $activitylink = html_writer::empty_tag('img', array('src' => $mod->get_icon_url(),
-                'class' => 'iconlarge activityicon', 'alt' => ' ', 'role' => 'presentation')) .
+                'class' => 'iconlarge activityicon', 'alt' => '', 'role' => 'presentation', 'aria-hidden' => 'true')) .
                 html_writer::tag('span', $instancename . $altname, array('class' => 'instancename'));
         if ($mod->uservisible) {
             $output .= html_writer::link($url, $activitylink, array('class' => $linkclasses, 'onclick' => $onclick));
@@ -2334,10 +2334,10 @@ class core_course_renderer extends plugin_renderer_base {
     /**
      * Output news for the frontpage (extract from site-wide news forum)
      *
-     * @param stdClass $newsforum record from db table 'forum' that represents the site news forum
+     * @param stdClass $forum record from db table 'forum' that represents the site news forum
      * @return string
      */
-    protected function frontpage_news($newsforum) {
+    protected function frontpage_news($forum) {
         global $CFG, $SITE, $SESSION, $USER;
         require_once($CFG->dirroot .'/mod/forum/lib.php');
 
@@ -2346,23 +2346,27 @@ class core_course_renderer extends plugin_renderer_base {
         if (isloggedin()) {
             $SESSION->fromdiscussion = $CFG->wwwroot;
             $subtext = '';
-            if (\mod_forum\subscriptions::is_subscribed($USER->id, $newsforum)) {
-                if (!\mod_forum\subscriptions::is_forcesubscribed($newsforum)) {
+            if (\mod_forum\subscriptions::is_subscribed($USER->id, $forum)) {
+                if (!\mod_forum\subscriptions::is_forcesubscribed($forum)) {
                     $subtext = get_string('unsubscribe', 'forum');
                 }
             } else {
                 $subtext = get_string('subscribe', 'forum');
             }
-            $suburl = new moodle_url('/mod/forum/subscribe.php', array('id' => $newsforum->id, 'sesskey' => sesskey()));
+            $suburl = new moodle_url('/mod/forum/subscribe.php', array('id' => $forum->id, 'sesskey' => sesskey()));
             $output .= html_writer::tag('div', html_writer::link($suburl, $subtext), array('class' => 'subscribelink'));
         }
 
-        ob_start();
-        forum_print_latest_discussions($SITE, $newsforum, $SITE->newsitems, 'plain', 'p.modified DESC');
-        $output .= ob_get_contents();
-        ob_end_clean();
+        $coursemodule = get_coursemodule_from_instance('forum', $forum->id);
+        $context = context_module::instance($coursemodule->id);
 
-        return $output;
+        $entityfactory = mod_forum\local\container::get_entity_factory();
+        $forumentity = $entityfactory->get_forum_from_stdclass($forum, $context, $coursemodule, $SITE);
+
+        $rendererfactory = mod_forum\local\container::get_renderer_factory();
+        $discussionsrenderer = $rendererfactory->get_frontpage_news_discussion_list_renderer($forumentity);
+        $cm = \cm_info::create($coursemodule);
+        return $output . $discussionsrenderer->render($USER, $cm, null, null, 0, $SITE->newsitems);
     }
 
     /**
index cdf5ea8..2596eb2 100644 (file)
@@ -22,14 +22,14 @@ Feature: Managers can create courses
     And I add the "Latest announcements" block
     Then "Latest announcements" "block" should exist
     And I follow "Announcements"
-    And "Add a new topic" "button" should exist
+    And "Add a new topic" "link" should exist
     And "Subscription mode > Forced subscription" "link" should not exist in current page administration
     And "Subscription mode > Forced subscription" "text" should exist in current page administration
     And I log out
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Announcements"
-    And "Add a new topic" "button" should not exist
+    And "Add a new topic" "link" should not exist
     And "Forced subscription" "text" should exist in current page administration
 
   Scenario: Create a course from the management interface and return to it
index 1cc2406..c491170 100644 (file)
@@ -54,6 +54,7 @@ class stored_file_exporter extends \core\external\exporter {
         $data->filepath = $file->get_filepath();
         $data->filename = $file->get_filename();
         $data->isdir = $file->is_directory();
+        $data->isimage = $file->is_valid_image();
         $data->timemodified = $file->get_timemodified();
         $data->timecreated = $file->get_timecreated();
         $data->filesize = $file->get_filesize();
@@ -94,6 +95,9 @@ class stored_file_exporter extends \core\external\exporter {
             'isdir' => array(
                 'type' => PARAM_BOOL
             ),
+            'isimage' => array(
+                'type' => PARAM_BOOL
+            ),
             'timemodified' => array(
                 'type' => PARAM_INT
             ),
index 38c195a..2eee814 100644 (file)
@@ -261,16 +261,32 @@ abstract class exporter {
     final public static function read_properties_definition() {
         $properties = static::properties_definition();
         $customprops = static::define_other_properties();
-        foreach ($customprops as $property => $definition) {
+        $customprops = static::format_properties($customprops);
+        $properties += $customprops;
+        return $properties;
+    }
+
+    /**
+     * Recursively formats a given property definition with the default fields required.
+     *
+     * @param array $properties List of properties to format
+     * @return array Formatted array
+     */
+    final public static function format_properties($properties) {
+        foreach ($properties as $property => $definition) {
             // Ensures that null is set to its default.
             if (!isset($definition['null'])) {
-                $customprops[$property]['null'] = NULL_NOT_ALLOWED;
+                $properties[$property]['null'] = NULL_NOT_ALLOWED;
             }
             if (!isset($definition['description'])) {
-                $customprops[$property]['description'] = $property;
+                $properties[$property]['description'] = $property;
+            }
+
+            // If an array is provided, it may be a nested array that is unformatted so rinse and repeat.
+            if (is_array($definition['type'])) {
+                $properties[$property]['type'] = static::format_properties($definition['type']);
             }
         }
-        $properties += $customprops;
         return $properties;
     }
 
diff --git a/lib/classes/external/paged_content_exporter.php b/lib/classes/external/paged_content_exporter.php
new file mode 100644 (file)
index 0000000..f1b2e7c
--- /dev/null
@@ -0,0 +1,131 @@
+<?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/>.
+
+/**
+ * Paged Content exporter.
+ *
+ * @package    core
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderer_base;
+
+/**
+ * Paged Content exporter.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class paged_content_exporter extends exporter {
+    /** @var int pagesize The number of records to show on each page */
+    private $pagesize;
+
+    /** @var int pagenumber The current page number */
+    private $pagenumber;
+
+    /** @var int recordcount The total number of records available */
+    private $recordcount;
+
+    /** @var callable The callback to use to determine a page URL */
+    private $pageurlcallback;
+
+    /**
+     * Constructor.
+     *
+     * @param int $pagesize The number of records to show on each page
+     * @param int $pagenumber The current page number
+     * @param int $recordcount The total number of records available
+     * @param callable $pageurlcallback The callback to use to determine a page URL
+     * @param array $related List of related elements
+     */
+    public function __construct(int $pagesize, int $pagenumber, int $recordcount, callable $pageurlcallback, array $related = []) {
+        $this->pagesize = $pagesize;
+        $this->pagenumber = $pagenumber;
+        $this->recordcount = $recordcount;
+        $this->pageurlcallback = $pageurlcallback;
+
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'itemsperpage' => ['type' => PARAM_INT],
+            'buttons' => [
+                'type' => [
+                    'first' => ['type' => PARAM_BOOL],
+                    'previous' => ['type' => PARAM_BOOL],
+                    'next' => ['type' => PARAM_BOOL],
+                    'last' => ['type' => PARAM_BOOL],
+                ],
+            ],
+            'pages' => [
+                'multiple' => true,
+                'type' => [
+                    'page' => ['type' => PARAM_INT],
+                    'url' => ['type' => PARAM_URL],
+                    'active' => ['type' => PARAM_BOOL],
+                    'content' => [
+                        'optional' => true,
+                        'type' => PARAM_RAW,
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $pagecount = ceil($this->recordcount / $this->pagesize);
+
+        $pages = [];
+        if ($pagecount > 1) {
+            for ($pageno = 1; $pageno <= $pagecount; $pageno++) {
+                $pages[] = [
+                    'page' => $pageno,
+                    'url' => call_user_func_array($this->pageurlcallback, [$pageno, $this->pagesize]),
+                    'active' => $pageno === $this->pagenumber,
+                    'content' => null,
+                ];
+            }
+        }
+
+        return [
+            'itemsperpage' => $this->pagesize,
+            'buttons' => [
+                'first' => false,
+                'previous' => false,
+                'next' => false,
+                'last' => false,
+            ],
+            'pages' => $pages,
+        ];
+    }
+}
index de30732..b35c1ed 100644 (file)
@@ -473,9 +473,21 @@ class manager {
      * @param array $processorlist the list of processors for a single user.
      */
     protected static function call_processors(message $eventdata, array $processorlist) {
+        // Allow plugins to change the message/notification data before sending it.
+        $pluginsfunction = get_plugins_with_function('pre_processor_message_send');
+
         foreach ($processorlist as $procname) {
             // Let new messaging class add custom content based on the processor.
             $proceventdata = ($eventdata instanceof message) ? $eventdata->get_eventobject_for_processor($procname) : $eventdata;
+
+            if ($pluginsfunction) {
+                foreach ($pluginsfunction as $plugintype => $plugins) {
+                    foreach ($plugins as $pluginfunction) {
+                        $pluginfunction($procname, $proceventdata);
+                    }
+                }
+            }
+
             $stdproc = new \stdClass();
             $stdproc->name = $procname;
             $processor = \core_message\api::get_processed_processor_object($stdproc);
index 93f8b59..1db678b 100644 (file)
@@ -162,6 +162,10 @@ class notification implements \renderable, \templatable {
             'extraclasses'  => implode(' ', $this->extraclasses),
             'announce'      => $this->announce,
             'closebutton'   => $this->closebutton,
+            'issuccess'         => $this->messagetype === 'success',
+            'isinfo'            => $this->messagetype === 'info',
+            'iswarning'         => $this->messagetype === 'warning',
+            'iserror'           => $this->messagetype === 'error',
         );
     }
 
index 9e781cd..72d78b2 100644 (file)
@@ -828,6 +828,65 @@ abstract class moodle_database {
         return array($sql, $params);
     }
 
+    /**
+     * Get the SELECT SQL to preload columns for the specified fieldlist and table alias.
+     *
+     * This function is intended to be used in combination with get_preload_columns_sql and extract_from_fields.
+     *
+     * @param   array       $fieldlist The list of fields from get_preload_columns
+     * @param   string      $tablealias The table alias used in the FROM/JOIN field
+     * @return  string      The SQL to use in the SELECT
+     */
+    public function get_preload_columns_sql(array $fieldlist, string $tablealias) : string {
+        return implode(', ', array_map(function($fieldname, $alias) use ($tablealias) {
+            return "{$tablealias}.{$fieldname} AS {$alias}";
+        }, $fieldlist, array_keys($fieldlist)));
+    }
+
+    /**
+     * Extract fields from the specified data.
+     * The fields are removed from the original object.
+     *
+     * This function is intended to be used in combination with get_preload_columns and get_preload_columns_sql.
+     *
+     * @param   array       $fieldlist The list of fields from get_preload_columns
+     * @param   \stdClass   $data The data retrieved from the database with fields to be extracted
+     * @return  string      The SQL to use in the SELECT
+     */
+    public function extract_fields_from_object(array $fieldlist, \stdClass $data) : \stdClass {
+        $newdata = (object) [];
+        foreach ($fieldlist as $alias => $fieldname) {
+            if (property_exists($data, $alias)) {
+                $newdata->$fieldname = $data->$alias;
+                unset($data->$alias);
+            } else {
+                debugging("Field '{$fieldname}' not found", DEBUG_DEVELOPER);
+            }
+        }
+
+        return $newdata;
+    }
+
+    /**
+     * Get the preload columns for the specified table and use the specified prefix in the column alias.
+     *
+     * This function is intended to be used in combination with get_preload_columns_sql and extract_from_fields.
+     *
+     * @param   string      $table
+     * @param   string      $prefix
+     * @return  array       The list of columns in a table. The array key is the column name with an applied prefix.
+     */
+    public function get_preload_columns(string $table, string $prefix) : array {
+        global $DB;
+
+        $fields = [];
+        foreach (array_keys($this->get_columns($table)) as $fieldname) {
+            $fields["{$prefix}{$fieldname}"] = $fieldname;
+        }
+
+        return $fields;
+    }
+
     /**
      * Converts short table name {tablename} to the real prefixed table name in given sql.
      * @param string $sql The sql to be operated on.
index fb144e9..f89378a 100644 (file)
@@ -794,6 +794,136 @@ class core_dml_testcase extends database_driver_testcase {
         $this->assertFalse($columns['id']->auto_increment);
     }
 
+    public function test_get_preload_columns() {
+        $DB = $this->tdb;
+        $dbman = $this->tdb->get_manager();
+
+        $table = $this->get_test_table();
+        $tablename = $table->getName();
+
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('course', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, null, null, 'lala');
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $dbman->create_table($table);
+
+        $expected = [
+            'aid' => 'id',
+            'acourse' => 'course',
+            'aname' => 'name',
+        ];
+        $columns = $DB->get_preload_columns($tablename, 'a');
+        $this->assertCount(3, $columns);
+        $this->assertEquals($expected, $columns);
+    }
+
+    /**
+     * Ensure that get_preload_columns_sql works as expected.
+     *
+     * @dataProvider get_preload_columns_sql_provider
+     * @param   array       $fieldlist The list of fields
+     * @param   string      $tablealias The alias to use
+     * @param   string      $expected The string to match
+     */
+    public function test_get_preload_columns_sql(array $fieldlist, string $tablealias, string $expected) {
+        $this->assertEquals($expected, $this->tdb->get_preload_columns_sql($fieldlist, $tablealias));
+    }
+
+    /**
+     * Data provider for get_preload_columns_sql tests.
+     *
+     * @return  array
+     */
+    public function get_preload_columns_sql_provider() : array {
+        return [
+            'single field' => [
+                [
+                    'xid' => 'id',
+                ],
+                'x',
+                'x.id AS xid',
+            ],
+            'multiple fields' => [
+                [
+                    'bananaid' => 'id',
+                    'bananacourse' => 'course',
+                    'bananafoo' => 'foo',
+                ],
+                'banana',
+                'banana.id AS bananaid, banana.course AS bananacourse, banana.foo AS bananafoo',
+            ],
+        ];
+    }
+
+    /**
+     * Ensure that extract_fields_from_object works as expected.
+     *
+     * @dataProvider        extract_fields_from_object_provider
+     * @param   array       $fieldlist The list of fields
+     * @param   stdClass    $in Input values for the test
+     * @param   stdClass    $out The expected output
+     * @param   stdClass    $modified Expected value of $in after it's been modified
+     */
+    public function test_extract_fields_from_object(array $fieldlist, \stdClass $in, \stdClass $out, \stdClass $modified) {
+        $result = $this->tdb->extract_fields_from_object($fieldlist, $in);
+        $this->assertEquals($out, $result);
+        $this->assertEquals($modified, $in);
+    }
+
+    /**
+     * Data provider for extract_fields_from_object tests.
+     *
+     * @return  array
+     */
+    public function extract_fields_from_object_provider() : array {
+        return [
+            'single table' => [
+                [
+                    'sid' => 'id',
+                    'scourse' => 'course',
+                    'sflag' => 'flag',
+                ],
+                (object) [
+                    'sid' => 1,
+                    'scourse' => 42,
+                    'sflag' => 'foo',
+                ],
+                (object) [
+                    'id' => 1,
+                    'course' => 42,
+                    'flag' => 'foo',
+                ],
+                (object) [
+                ],
+            ],
+            'single table amongst others' => [
+                [
+                    'sid' => 'id',
+                    'scourse' => 'course',
+                    'sflag' => 'flag',
+                ],
+                (object) [
+                    'sid' => 1,
+                    'scourse' => 42,
+                    'sflag' => 'foo',
+                    'oid' => 'id',
+                    'ocourse' => 'course',
+                    'oflag' => 'flag',
+                ],
+                (object) [
+                    'id' => 1,
+                    'course' => 42,
+                    'flag' => 'foo',
+                ],
+                (object) [
+                    'oid' => 'id',
+                    'ocourse' => 'course',
+                    'oflag' => 'flag',
+                ],
+            ],
+        ];
+    }
+
     public function test_get_manager() {
         $DB = $this->tdb;
         $dbman = $this->tdb->get_manager();
index 1796191..c958000 100644 (file)
@@ -206,7 +206,7 @@ class external_api {
             }
 
             // Do not allow access to write or delete webservices as a public user.
-            if ($externalfunctioninfo->loginrequired) {
+            if ($externalfunctioninfo->loginrequired && !WS_SERVER) {
                 if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
                     throw new moodle_exception('servicerequireslogin', 'webservice');
                 }
index b096a5b..e69fc91 100644 (file)
@@ -29,6 +29,12 @@ defined('MOODLE_INTERNAL') || die();
  */
 define('BYTESERVING_BOUNDARY', 's1k2o3d4a5k6s7');
 
+
+/**
+ * Do not process file merging when working with draft area files.
+ */
+define('IGNORE_FILE_MERGE', -1);
+
 /**
  * Unlimited area size constant
  */
@@ -888,6 +894,7 @@ function file_remove_editor_orphaned_files($editor) {
  *
  * @category files
  * @param int $draftitemid the id of the primary draft area.
+ *            When set to -1 (probably, by a WebService) it won't process file merging, keeping the original state of the file area.
  * @param int $usercontextid the user's context id.
  * @param string $text some html content that needs to have files copied to the correct draft area.
  * @param bool $forcehttps force https urls.
@@ -899,6 +906,11 @@ function file_merge_draft_areas($draftitemid, $usercontextid, $text, $forcehttps
         return null;
     }
 
+    // Do not merge files, leave it as it was.
+    if ($draftitemid === IGNORE_FILE_MERGE) {
+        return null;
+    }
+
     $urls = extract_draft_file_urls_from_text($text, $forcehttps, $usercontextid, 'user', 'draft');
 
     // No draft areas to rewrite.
@@ -1015,6 +1027,7 @@ function file_copy_file_to_file_area($file, $filename, $itemid) {
  * @global stdClass $USER
  * @param int $draftitemid the id of the draft area to use. Normally obtained
  *      from file_get_submitted_draft_itemid('elementname') or similar.
+ *      When set to -1 (probably, by a WebService) it won't process file merging, keeping the original state of the file area.
  * @param int $contextid This parameter and the next two identify the file area to save to.
  * @param string $component
  * @param string $filearea indentifies the file area.
@@ -1028,6 +1041,11 @@ function file_copy_file_to_file_area($file, $filename, $itemid) {
 function file_save_draft_area_files($draftitemid, $contextid, $component, $filearea, $itemid, array $options=null, $text=null, $forcehttps=false) {
     global $USER;
 
+    // Do not merge files, leave it as it was.
+    if ($draftitemid === IGNORE_FILE_MERGE) {
+        return null;
+    }
+
     $usercontext = context_user::instance($USER->id);
     $fs = get_file_storage();
 
index 2c1906d..b297159 100644 (file)
@@ -598,7 +598,7 @@ class file_storage {
      * @param int $contextid context ID
      * @param string $component component
      * @param mixed $filearea file area/s, you cannot specify multiple fileareas as well as an itemid
-     * @param int $itemid item ID or all files if not specified
+     * @param int|int[]|false $itemid item ID(s) or all files if not specified
      * @param string $sort A fragment of SQL to use for sorting
      * @param bool $includedirs whether or not include directories
      * @param int $updatedsince return files updated since this time
@@ -617,8 +617,10 @@ class file_storage {
         if ($itemid !== false && is_array($filearea)) {
             throw new coding_exception('You cannot specify multiple fileareas as well as an itemid.');
         } else if ($itemid !== false) {
-            $itemidsql = ' AND f.itemid = :itemid ';
-            $conditions['itemid'] = $itemid;
+            $itemids = is_array($itemid) ? $itemid : [$itemid];
+            list($itemidinorequalsql, $itemidconditions) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
+            $itemidsql = " AND f.itemid {$itemidinorequalsql}";
+            $conditions = array_merge($conditions, $itemidconditions);
         } else {
             $itemidsql = '';
         }
index a28766e..224c2be 100644 (file)
@@ -92,7 +92,7 @@ Feature: Using the activity grade form element
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I press "Add a new discussion topic"
+    And I click on "Add a new discussion topic" "link"
     And I set the following fields to these values:
       | Subject  | Discussion subject |
       | Message | Discussion message |
@@ -170,7 +170,7 @@ Feature: Using the activity grade form element
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I press "Add a new discussion topic"
+    And I click on "Add a new discussion topic" "link"
     And I set the following fields to these values:
       | Subject  | Discussion subject |
       | Message | Discussion message |
index 04e5965..0b62014 100644 (file)
@@ -200,3 +200,9 @@ define('PORTFOLIO_ADD_TEXT_LINK', 4);
  *                          this replaces the old portfolio_fake_add_url function
  */
 define('PORTFOLIO_ADD_FAKE_URL', 5);
+
+/**
+ * PORTFOLIO_ADD_MOODULE_URL - hacky way to turn the button class into a moodle_url to redirect to
+ *                             this replaces the old portfolio_fake_add_url function
+ */
+define('PORTFOLIO_ADD_MOODLE_URL', 6);
index 68fcf86..f37d3a2 100644 (file)
@@ -259,7 +259,7 @@ class portfolio_add_button {
      *                    Optional, defaults to PORTFOLIO_ADD_FULL_FORM
      * @param string $addstr string to use for the button or icon alt text or link text.
      *                       This is whole string, not key.  optional, defaults to 'Add to portfolio';
-     * @return void|string
+     * @return void|string|moodle_url
      */
     public function to_html($format=null, $addstr=null) {
         global $CFG, $COURSE, $OUTPUT, $USER;
@@ -327,6 +327,11 @@ class portfolio_add_button {
                 return;
             }
         }
+        // If we just want a moodle_url to redirect to, do it now.
+        if ($format == PORTFOLIO_ADD_MOODLE_URL) {
+            return $url;
+        }
+
         // if we just want a url to redirect to, do it now
         if ($format == PORTFOLIO_ADD_FAKE_URL) {
             return $url->out(false);
diff --git a/lib/templates/notification.mustache b/lib/templates/notification.mustache
new file mode 100644 (file)
index 0000000..d610404
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    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/>.
+}}
+{{!
+    @template core/notification
+
+    Moodle notification template.
+
+    The purpose of this template is to render a notification using other notification templates.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * message A cleaned string (use clean_text()) to display.
+    * extraclasses Additional classes to apply to the notification.
+    * closebutton Whether a close button should be displayed to dismiss the message.
+    * announce Whether the notification should be announced to screen readers.
+
+    Example context (json):
+    { "message": "Your pants are on fire!", "closebutton": 1, "announce": 1, "extraclasses": "foo bar", "issuccess": 1}
+}}
+{{#issuccess}}
+    {{> core/notification_success}}
+{{/issuccess}}
+{{#isinfo}}
+    {{> core/notification_info}}
+{{/isinfo}}
+{{#iswarning}}
+    {{> core/notification_warning}}
+{{/iswarning}}
+{{#iserror}}
+    {{> core/notification_error}}
+{{/iserror}}
index d886884..3bfdfd2 100644 (file)
@@ -40,55 +40,55 @@ Feature: Context freezing apply to child contexts
     Given I log in as "admin"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    And "Add a new discussion topic" "button" should exist
+    And "Add a new discussion topic" "link" should exist
     When I follow "Freeze this context"
     And I click on "Continue" "button"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should see "Turn editing on"
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseaa2" course homepage
     Then I should see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "teacher"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should see "Turn editing on"
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseaa2" course homepage
     Then I should see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    And "Add a new discussion topic" "button" should exist
+    And "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "student1"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseaa2" course homepage
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
   Scenario: Freeze course should freeze all children
     Given I log in as "admin"
@@ -97,54 +97,54 @@ Feature: Context freezing apply to child contexts
     When I follow "Freeze this context"
     And I click on "Continue" "button"
     Then I should not see "Turn editing on"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should not see "Turn editing on"
     And "Unfreeze this context" "link" should exist in current page administration
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     And "Unfreeze this context" "link" should not exist in current page administration
     When I am on "courseaa2" course homepage
     Then I should see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "teacher"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should not see "Turn editing on"
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa2" course homepage
     Then I should see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "student1"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa2" course homepage
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
     When I am on "courseb" course homepage
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
   Scenario: Freeze course category should freeze all children
     Given I log in as "admin"
@@ -153,53 +153,53 @@ Feature: Context freezing apply to child contexts
     And I click on "Continue" "button"
     And I am on "courseaa1" course homepage
     And I should not see "Turn editing on"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should not see "Turn editing on"
     And "Unfreeze this context" "link" should not exist in current page administration
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     And "Unfreeze this context" "link" should not exist in current page administration
     When I am on "courseaa2" course homepage
     Then I should not see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     And "Unfreeze this context" "link" should not exist in current page administration
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "teacher"
     And I am on "courseaa1" course homepage
     Then I should not see "Turn editing on"
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     Then I should not see "Turn editing on"
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa2" course homepage
     Then I should not see "Turn editing on"
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseb" course homepage
     Then I should see "Turn editing on"
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
 
     And I log out
     When I log in as "student1"
     And I am on "courseaa1" course homepage
     And I follow "faa1"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa1" course homepage
     When I follow "faa1b"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseaa2" course homepage
     When I follow "faa2"
-    Then "Add a new discussion topic" "button" should not exist
+    Then "Add a new discussion topic" "link" should not exist
     When I am on "courseb" course homepage
     When I follow "fb"
-    Then "Add a new discussion topic" "button" should exist
+    Then "Add a new discussion topic" "link" should exist
index f456628..57929ca 100644 (file)
@@ -179,6 +179,8 @@ class core_exporter_testcase extends advanced_testcase {
         $this->assertEquals('otherstring description', $properties['otherstring']['description']);
         // Other properties default description.
         $this->assertEquals('otherstrings', $properties['otherstrings']['description']);
+        // Assert nested elements are formatted correctly.
+        $this->assertEquals('id', $properties['nestedarray']['type']['id']['description']);
     }
 }
 
@@ -228,6 +230,13 @@ class core_testable_exporter extends \core\external\exporter {
             'otherstrings' => array(
                 'type' => PARAM_TEXT,
                 'multiple' => true
+            ),
+            'nestedarray' => array(
+                'multiple' => true,
+                'optional' => true,
+                'type' => [
+                    'id' => ['type' => PARAM_INT]
+                ]
             )
         );
     }
index e7480dc..390e70a 100644 (file)
@@ -591,6 +591,11 @@ class core_externallib_testcase extends advanced_testcase {
         $files = external_util::get_area_files($context, $component, $filearea, false);
         $this->assertEquals($expectedfiles, $files);
 
+        $DB->method('get_in_or_equal')->willReturn([
+            '= :mock1',
+            ['mock1' => $itemid]
+        ]);
+
         // Get just the file indicated by $itemid.
         $files = external_util::get_area_files($context, $component, $filearea, $itemid);
         $this->assertEquals($expectedfiles, $files);
index d417ad3..8adb430 100644 (file)
@@ -807,6 +807,70 @@ class core_filelib_testcase extends advanced_testcase {
         $this->assertEquals($filecontent, $fileref->get_content());
     }
 
+    /**
+     * Test avoid file merging when working with draft areas.
+     */
+    public function test_ignore_file_merging_in_draft_area() {
+        global $USER, $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $usercontext = context_user::instance($user->id);
+        $USER = $DB->get_record('user', array('id' => $user->id));
+
+        $repositorypluginname = 'user';
+
+        $args = array();
+        $args['type'] = $repositorypluginname;
+        $repos = repository::get_instances($args);
+        $userrepository = reset($repos);
+        $this->assertInstanceOf('repository', $userrepository);
+
+        $fs = get_file_storage();
+        $syscontext = context_system::instance();
+
+        $filecontent = 'User file content';
+
+        // Create a user private file.
+        $userfilerecord = new stdClass;
+        $userfilerecord->contextid = $usercontext->id;
+        $userfilerecord->component = 'user';
+        $userfilerecord->filearea  = 'private';
+        $userfilerecord->itemid    = 0;
+        $userfilerecord->filepath  = '/';
+        $userfilerecord->filename  = 'userfile.txt';
+        $userfilerecord->source    = 'test';
+        $userfile = $fs->create_file_from_string($userfilerecord, $filecontent);
+        $userfileref = $fs->pack_reference($userfilerecord);
+        $contenthash = $userfile->get_contenthash();
+
+        $filerecord = array(
+            'contextid' => $syscontext->id,
+            'component' => 'core',
+            'filearea'  => 'phpunit',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => 'test.txt',
+        );
+        // Create a file reference.
+        $fileref = $fs->create_file_from_reference($filerecord, $userrepository->id, $userfileref);
+        $this->assertCount(2, $fs->get_area_files($usercontext->id, 'user', 'private'));    // 2 because includes the '.' file.
+
+        // Save using empty draft item id, all files will be deleted.
+        file_save_draft_area_files(0, $usercontext->id, 'user', 'private', 0);
+        $this->assertCount(0, $fs->get_area_files($usercontext->id, 'user', 'private'));
+
+        // Create a file again.
+        $userfile = $fs->create_file_from_string($userfilerecord, $filecontent);
+        $this->assertCount(2, $fs->get_area_files($usercontext->id, 'user', 'private'));
+
+        // Save without merge.
+        file_save_draft_area_files(IGNORE_FILE_MERGE, $usercontext->id, 'user', 'private', 0);
+        $this->assertCount(2, $fs->get_area_files($usercontext->id, 'user', 'private'));
+    }
+
     /**
      * Tests the strip_double_headers function in the curl class.
      */
index b7a649a..ab0d4e8 100644 (file)
@@ -22,6 +22,10 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
 * Behat timeout constants behat_base::TIMEOUT, EXTENDED_TIMEOUT, and REDUCED_TIMEOUT have been
   deprecated. Please instead use the functions behat_base::get_timeout(), get_extended_timeout(),
   and get_reduced_timeout(). These allow for timeouts to be increased by a setting in config.php.
+* The $draftitemid parameter of file_save_draft_area_files() function now supports the constant IGNORE_FILE_MERGE:
+  When the parameter is set to that constant, the function won't process file merging, keeping the original state of the file area.
+* Introduced new callback for plugin developers '<component>_pre_processor_message_send($procname, $proceventdata)':
+  This will allow any plugin to manipulate messages or notifications before they are sent by a processor (email, mobile...)
 
 === 3.6 ===
 
@@ -637,13 +641,13 @@ the groupid field.
       $OUTPUT->download_dataformat_selector() instead.
   when building Xpath, or pass the unescaped value when using the named selector.
 * Add new file_is_executable(), to consistently check for executables even in Windows (PHP bug #41062).
-* Introduced new hooks for plugin developers.
+* Introduced new callbacks for plugin developers.
     - <component>_pre_course_category_delete($category)
     - <component>_pre_course_delete($course)
     - <component>_pre_course_module_delete($cm)
     - <component>_pre_block_delete($instance)
     - <component>_pre_user_delete($user)
-  These hooks allow developers to use the item in question before it is deleted by core. For example, if your plugin is
+  These callbacks allow developers to use the item in question before it is deleted by core. For example, if your plugin is
   a module (plugins located in the mod folder) called 'xxx' and you wish to interact with the user object before it is
   deleted then the function to create would be mod_xxx_pre_user_delete($user) in mod/xxx/lib.php.
 * pear::Net::GeoIP has been removed.
index e11e2e3..b02579f 100644 (file)
@@ -383,8 +383,8 @@ function upgrade_block_savepoint($result, $version, $blockname, $allowabort=true
  * @category upgrade
  * @param bool $result false if upgrade step failed, true if completed
  * @param string or float $version main version
- * @param string $type name of plugin
- * @param string $dir location of plugin
+ * @param string $type The type of the plugin.
+ * @param string $plugin The name of the plugin.
  * @param bool $allowabort allow user to abort script execution here
  * @return void
  */
diff --git a/mod/forum/amd/build/discussion_list.min.js b/mod/forum/amd/build/discussion_list.min.js
new file mode 100644 (file)
index 0000000..0f7bc8b
Binary files /dev/null and b/mod/forum/amd/build/discussion_list.min.js differ
diff --git a/mod/forum/amd/build/repository.min.js b/mod/forum/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..2b3fcee
Binary files /dev/null and b/mod/forum/amd/build/repository.min.js differ
diff --git a/mod/forum/amd/build/selectors.min.js b/mod/forum/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..8a80492
Binary files /dev/null and b/mod/forum/amd/build/selectors.min.js differ
diff --git a/mod/forum/amd/build/subscription_toggle.min.js b/mod/forum/amd/build/subscription_toggle.min.js
new file mode 100644 (file)
index 0000000..4ff0470
Binary files /dev/null and b/mod/forum/amd/build/subscription_toggle.min.js differ
diff --git a/mod/forum/amd/src/discussion_list.js b/mod/forum/amd/src/discussion_list.js
new file mode 100644 (file)
index 0000000..f2bfab9
--- /dev/null
@@ -0,0 +1,30 @@
+// 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 for the list of discussions on when viewing a forum.
+ *
+ * @module     mod_forum/discussion_list
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['mod_forum/subscription_toggle'], function(SubscriptionToggle) {
+    return {
+        init: function(root) {
+            SubscriptionToggle.init(root);
+        }
+    };
+});
diff --git a/mod/forum/amd/src/repository.js b/mod/forum/amd/src/repository.js
new file mode 100644 (file)
index 0000000..c09a6d8
--- /dev/null
@@ -0,0 +1,49 @@
+// 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/>.
+
+/**
+ * Forum repository class to encapsulate all of the AJAX requests that
+ * can be sent for forum.
+ *
+ * @module     mod_forum/repository
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['core/ajax'], function(Ajax) {
+    /**
+     * Set the subscription state for a discussion in a forum.
+     *
+     * @param {number} forumId ID of the forum the discussion belongs to
+     * @param {number} discussionId ID of the discussion with the subscription state
+     * @param {boolean} targetState Set the subscribed state. True == subscribed; false == unsubscribed.
+     * @return {object} jQuery promise
+     */
+    var setDiscussionSubscriptionState = function(forumId, discussionId, targetState) {
+        var request = {
+            methodname: 'mod_forum_set_subscription_state',
+            args: {
+                forumid: forumId,
+                discussionid: discussionId,
+                targetstate: targetState
+            }
+        };
+        return Ajax.call([request])[0];
+    };
+
+    return {
+        setDiscussionSubscriptionState: setDiscussionSubscriptionState,
+    };
+});
diff --git a/mod/forum/amd/src/selectors.js b/mod/forum/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..e547569
--- /dev/null
@@ -0,0 +1,30 @@
+// 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/>.
+
+/**
+ * Common CSS selectors for the forum UI.
+ *
+ * @module     mod_forum/selectors
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    return {
+        subscription: {
+            toggle: "[data-type='subscription-toggle'][data-action='toggle']",
+        }
+    };
+});
diff --git a/mod/forum/amd/src/subscription_toggle.js b/mod/forum/amd/src/subscription_toggle.js
new file mode 100644 (file)
index 0000000..f4f39b3
--- /dev/null
@@ -0,0 +1,69 @@
+// 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/>.
+
+/**
+ * Handle discussion subscription toggling on a discussion list in
+ * the forum view.
+ *
+ * @module     mod_forum/subscription_toggle
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+        'jquery',
+        'core/templates',
+        'core/notification',
+        'mod_forum/repository',
+        'mod_forum/selectors',
+    ], function(
+        $,
+        Templates,
+        Notification,
+        Repository,
+        Selectors
+    ) {
+
+    /**
+     * Register event listeners for the subscription toggle.
+     *
+     * @param {object} root The discussion list root element
+     */
+    var registerEventListeners = function(root) {
+        root.on('click', Selectors.subscription.toggle, function(e) {
+            var toggleElement = $(this);
+            var forumId = toggleElement.data('forumid');
+            var discussionId = toggleElement.data('discussionid');
+            var subscriptionState = toggleElement.data('targetstate');
+
+            Repository.setDiscussionSubscriptionState(forumId, discussionId, subscriptionState)
+                .then(function(context) {
+                    return Templates.render('mod_forum/discussion_subscription_toggle', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNode(toggleElement, html, js);
+                })
+                .catch(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
+    return {
+        init: function(root) {
+            registerEventListeners(root);
+        }
+    };
+});
diff --git a/mod/forum/classes/local/builders/exported_discussion_summaries.php b/mod/forum/classes/local/builders/exported_discussion_summaries.php
new file mode 100644 (file)
index 0000000..3064981
--- /dev/null
@@ -0,0 +1,198 @@
+<?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/>.
+
+/**
+ * Exported discussion summaries builder class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\builders;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\exporter as exporter_factory;
+use mod_forum\local\factories\vault as vault_factory;
+use rating_manager;
+use renderer_base;
+use stdClass;
+
+/**
+ * Exported discussion summaries builder class.
+ *
+ * This class is an implementation of the builder pattern (loosely). It is responsible
+ * for taking a set of related forums, discussions, and posts and generate the exported
+ * version of the discussion summaries.
+ *
+ * It encapsulates the complexity involved with exporting discussions summaries. All of the relevant
+ * additional resources will be loaded by this class in order to ensure the exporting
+ * process can happen.
+ *
+ * See this doc for more information on the builder pattern:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/Builder/README.html
+ *
+ * @package    mod_forum
+ * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exported_discussion_summaries {
+    /** @var renderer_base $renderer Core renderer */
+    private $renderer;
+
+    /** @var legacy_data_mapper_factory $legacydatamapperfactory Data mapper factory */
+    private $legacydatamapperfactory;
+
+    /** @var exporter_factory $exporterfactory Exporter factory */
+    private $exporterfactory;
+
+    /** @var vault_factory $vaultfactory Vault factory */
+    private $vaultfactory;
+
+    /** @var rating_manager $ratingmanager Rating manager */
+    private $ratingmanager;
+
+    /**
+     * Constructor.
+     *
+     * @param renderer_base $renderer Core renderer
+     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
+     * @param exporter_factory $exporterfactory Exporter factory
+     * @param vault_factory $vaultfactory Vault factory
+     * @param rating_manager $ratingmanager Rating manager
+     */
+    public function __construct(
+        renderer_base $renderer,
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        exporter_factory $exporterfactory,
+        vault_factory $vaultfactory,
+        rating_manager $ratingmanager
+    ) {
+        $this->renderer = $renderer;
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->exporterfactory = $exporterfactory;
+        $this->vaultfactory = $vaultfactory;
+        $this->ratingmanager = $ratingmanager;
+    }
+
+    /**
+     * Build the exported discussion summaries for a given set of discussions.
+     *
+     * This will typically be used for a list of discussions in the same forum.
+     *
+     * @param stdClass $user The user to export the posts for.
+     * @param forum_entity $forum The forum that each of the $discussions belong to
+     * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to
+     * @return stdClass[] List of exported posts in the same order as the $posts array.
+     */
+    public function build(
+        stdClass $user,
+        forum_entity $forum,
+        array $discussions
+    ) : array {
+
+        $discussionids = array_keys($discussions);
+
+        $postvault = $this->vaultfactory->get_post_vault();
+        $posts = $postvault->get_from_discussion_ids($discussionids);
+        $groupsbyid = $this->get_groups_available_in_forum($forum);
+        $groupsbyauthorid = $this->get_author_groups_from_posts($posts, $forum);
+
+        $replycounts = $postvault->get_reply_count_for_discussion_ids($discussionids);
+        $latestposts = $postvault->get_latest_post_id_for_discussion_ids($discussionids);
+
+        $unreadcounts = [];
+
+        $forumdatamapper = $this->legacydatamapperfactory->get_forum_data_mapper();
+        $forumrecord = $forumdatamapper->to_legacy_object($forum);
+
+        if (forum_tp_can_track_forums($forumrecord)) {
+            $unreadcounts = $postvault->get_unread_count_for_discussion_ids($user, $discussionids);
+        }
+
+        $summaryexporter = $this->exporterfactory->get_discussion_summaries_exporter(
+            $user,
+            $forum,
+            $discussions,
+            $groupsbyid,
+            $groupsbyauthorid,
+            $replycounts,
+            $unreadcounts,
+            $latestposts
+        );
+
+        return (array) $summaryexporter->export($this->renderer);
+    }
+
+    /**
+     * Get the groups details for all groups available to the forum.
+     * @param forum_entity $forum The forum entity
+     * @return stdClass[]
+     */
+    private function get_groups_available_in_forum($forum) : array {
+        $course = $forum->get_course_record();
+        $coursemodule = $forum->get_course_module_record();
+
+        return groups_get_all_groups($course->id, 0, $coursemodule->groupingid);
+    }
+
+    /**
+     * Get the author's groups for a list of posts.
+     *
+     * @param post_entity[] $posts The list of posts
+     * @param forum_entity $forum The forum entity
+     * @return array Author groups indexed by author id
+     */
+    private function get_author_groups_from_posts(array $posts, $forum) : array {
+        $course = $forum->get_course_record();
+        $coursemodule = $forum->get_course_module_record();
+        $authorids = array_reduce($posts, function($carry, $post) {
+            $carry[$post->get_author_id()] = true;
+            return $carry;
+        }, []);
+        $authorgroups = groups_get_all_groups($course->id, array_keys($authorids), $coursemodule->groupingid,
+                'g.*, gm.id, gm.groupid, gm.userid');
+
+        $authorgroups = array_reduce($authorgroups, function($carry, $group) {
+            // Clean up data returned from groups_get_all_groups.
+            $userid = $group->userid;
+            $groupid = $group->groupid;
+
+            unset($group->userid);
+            unset($group->groupid);
+            $group->id = $groupid;
+
+            if (!isset($carry[$userid])) {
+                $carry[$userid] = [$group];
+            } else {
+                $carry[$userid][] = $group;
+            }
+
+            return $carry;
+        }, []);
+
+        foreach (array_diff(array_keys($authorids), array_keys($authorgroups)) as $authorid) {
+            $authorgroups[$authorid] = [];
+        }
+
+        return $authorgroups;
+    }
+}
diff --git a/mod/forum/classes/local/builders/exported_posts.php b/mod/forum/classes/local/builders/exported_posts.php
new file mode 100644 (file)
index 0000000..1f5a7b7
--- /dev/null
@@ -0,0 +1,503 @@
+<?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/>.
+
+/**
+ * Exported post builder class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\builders;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\exporter as exporter_factory;
+use mod_forum\local\factories\vault as vault_factory;
+use context;
+use core_tag_tag;
+use moodle_exception;
+use rating_manager;
+use renderer_base;
+use stdClass;
+
+/**
+ * Exported post builder class.
+ *
+ * This class is an implementation of the builder pattern (loosely). It is responsible
+ * for taking a set of related forums, discussions, and posts and generate the exported
+ * version of the posts.
+ *
+ * It encapsulates the complexity involved with exporting posts. All of the relevant
+ * additional resources will be loaded by this class in order to ensure the exporting
+ * process can happen.
+ *
+ * See this doc for more information on the builder pattern:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/Builder/README.html
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exported_posts {
+    /** @var renderer_base $renderer Core renderer */
+    private $renderer;
+
+    /** @var legacy_data_mapper_factory $legacydatamapperfactory Data mapper factory */
+    private $legacydatamapperfactory;
+
+    /** @var exporter_factory $exporterfactory Exporter factory */
+    private $exporterfactory;
+
+    /** @var vault_factory $vaultfactory Vault factory */
+    private $vaultfactory;
+
+    /** @var rating_manager $ratingmanager Rating manager */
+    private $ratingmanager;
+
+    /**
+     * Constructor.
+     *
+     * @param renderer_base $renderer Core renderer
+     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
+     * @param exporter_factory $exporterfactory Exporter factory
+     * @param vault_factory $vaultfactory Vault factory
+     * @param rating_manager $ratingmanager Rating manager
+     */
+    public function __construct(
+        renderer_base $renderer,
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        exporter_factory $exporterfactory,
+        vault_factory $vaultfactory,
+        rating_manager $ratingmanager
+    ) {
+        $this->renderer = $renderer;
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->exporterfactory = $exporterfactory;
+        $this->vaultfactory = $vaultfactory;
+        $this->ratingmanager = $ratingmanager;
+    }
+
+    /**
+     * Build the exported posts for a given set of forums, discussions, and posts.
+     *
+     * This will typically be used for a list of posts in the same discussion/forum however
+     * it does support exporting any arbitrary list of posts as long as the caller also provides
+     * a unique list of all discussions for the list of posts and all forums for the list of discussions.
+     *
+     * Increasing the number of different forums being processed will increase the processing time
+     * due to processing multiple contexts (for things like capabilities, files, etc). The code attempts
+     * to load the additional resources as efficiently as possible but there is no way around some of
+     * the additional overhead.
+     *
+     * @param stdClass $user The user to export the posts for.
+     * @param forum_entity[] $forums A list of all forums that each of the $discussions belong to
+     * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to
+     * @param post_entity[] $posts The list of posts to export.
+     * @return stdClass[] List of exported posts in the same order as the $posts array.
+     */
+    public function build(
+        stdClass $user,
+        array $forums,
+        array $discussions,
+        array $posts
+    ) : array {
+        // Format the forums and discussion to make them more easily accessed later.
+        $forums = array_reduce($forums, function($carry, $forum) {
+            $carry[$forum->get_id()] = $forum;
+            return $carry;
+        }, []);
+        $discussions = array_reduce($discussions, function($carry, $discussion) {
+            $carry[$discussion->get_id()] = $discussion;
+            return $carry;
+        }, []);
+
+        // Group the posts by discussion and forum so that we can load the resources in
+        // batches to improve performance.
+        $groupedposts = $this->group_posts_by_discussion($forums, $discussions, $posts);
+        // Load all of the resources we need in order to export the posts.
+        $authorsbyid = $this->get_authors_for_posts($posts);
+        $attachmentsbypostid = $this->get_attachments_for_posts($groupedposts);
+        $groupsbycourseandauthorid = $this->get_author_groups_from_posts($groupedposts);
+        $tagsbypostid = $this->get_tags_from_posts($posts);
+        $ratingbypostid = $this->get_ratings_from_posts($user, $groupedposts);
+        $readreceiptcollectionbyforumid = $this->get_read_receipts_from_posts($user, $groupedposts);
+        $exportedposts = [];
+
+        // Export each set of posts per discussion because it's the largest chunks we can
+        // break them into due to constraints on capability checks.
+        foreach ($groupedposts as $grouping) {
+            [
+                'forum' => $forum,
+                'discussion' => $discussion,
+                'posts' => $groupedposts
+            ] = $grouping;
+
+            $forumid = $forum->get_id();
+            $courseid = $forum->get_course_record()->id;
+            $postsexporter = $this->exporterfactory->get_posts_exporter(
+                $user,
+                $forum,
+                $discussion,
+                $groupedposts,
+                $authorsbyid,
+                $attachmentsbypostid,
+                $groupsbycourseandauthorid[$courseid],
+                $readreceiptcollectionbyforumid[$forumid] ?? null,
+                $tagsbypostid,
+                $ratingbypostid,
+                true
+            );
+            ['posts' => $exportedgroupedposts] = (array) $postsexporter->export($this->renderer);
+            $exportedposts = array_merge($exportedposts, $exportedgroupedposts);
+        }
+
+        if (count($forums) == 1 && count($discussions) == 1) {
+            // All of the posts belong to a single discussion in a single forum so
+            // the exported order will match the given $posts array.
+            return $exportedposts;
+        } else {
+            // Since we grouped the posts by discussion and forum the ordering of the
+            // exported posts may be different to the given $posts array so we should
+            // sort it back into the correct order for the caller.
+            return $this->sort_exported_posts($posts, $exportedposts);
+        }
+    }
+
+    /**
+     * Group the posts by which discussion they belong to in order for them to be processed
+     * in chunks by the exporting.
+     *
+     * Returns a list of groups where each group has a forum, discussion, and list of posts.
+     * E.g.
+     * [
+     *      [
+     *          'forum' => <forum_entity>,
+     *          'discussion' => <discussion_entity>,
+     *          'posts' => [
+     *              <post_entity in discussion>,
+     *              <post_entity in discussion>,
+     *              <post_entity in discussion>
+     *          ]
+     *      ]
+     * ]
+     *
+     * @param forum_entity[] $forums A list of all forums that each of the $discussions belong to, indexed by id.
+     * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to, indexed by id.
+     * @param post_entity[] $posts The list of posts to process.
+     * @return array List of grouped posts. Each group has a discussion, forum, and posts.
+     */
+    private function group_posts_by_discussion(array $forums, array $discussions, array $posts) : array {
+        return array_reduce($posts, function($carry, $post) use ($forums, $discussions) {
+            $discussionid = $post->get_discussion_id();
+            if (!isset($discussions[$discussionid])) {
+                throw new moodle_exception('Unable to find discussion with id ' . $discussionid);
+            }
+
+            if (isset($carry[$discussionid])) {
+                $carry[$discussionid]['posts'][] = $post;
+            } else {
+                $discussion = $discussions[$discussionid];
+                $forumid = $discussion->get_forum_id();
+
+                if (!isset($forums[$forumid])) {
+                    throw new moodle_exception('Unable to find forum with id ' . $forumid);
+                }
+
+                $carry[$discussionid] = [
+                    'forum' => $forums[$forumid],
+                    'discussion' => $discussions[$discussionid],
+                    'posts' => [$post]
+                ];
+            }
+
+            return $carry;
+        }, []);
+    }
+
+    /**
+     * Load the list of authors for the given posts.
+     *
+     * The list of authors will be indexed by the author id.
+     *
+     * @param post_entity[] $posts The list of posts to process.
+     * @return author_entity[]
+     */
+    private function get_authors_for_posts(array $posts) : array {
+        $authorvault = $this->vaultfactory->get_author_vault();
+        return $authorvault->get_authors_for_posts($posts);
+    }
+
+    /**
+     * Load the list of all attachments for the posts. The list of attachments will be
+     * indexed by the post id.
+     *
+     * @param array $groupedposts List of posts grouped by discussions.
+     * @return stored_file[]
+     */
+    private function get_attachments_for_posts(array $groupedposts) : array {
+        $attachmentsbypostid = [];
+        $postattachmentvault = $this->vaultfactory->get_post_attachment_vault();
+        $postsbyforum = array_reduce($groupedposts, function($carry, $grouping) {
+            ['forum' => $forum, 'posts' => $posts] = $grouping;
+
+            $forumid = $forum->get_id();
+            if (!isset($carry[$forumid])) {
+                $carry[$forumid] = [
+                    'forum' => $forum,
+                    'posts' => []
+                ];
+            }
+
+            $carry[$forumid]['posts'] = array_merge($carry[$forumid]['posts'], $posts);
+            return $carry;
+        }, []);
+
+        foreach ($postsbyforum as $grouping) {
+            ['forum' => $forum, 'posts' => $posts] = $grouping;
+            $attachments = $postattachmentvault->get_attachments_for_posts($forum->get_context(), $posts);
+
+            // Have to loop in order to maintain the correct indexes since they are numeric.
+            foreach ($attachments as $postid => $attachment) {
+                $attachmentsbypostid[$postid] = $attachment;
+            }
+        }
+
+        return $attachmentsbypostid;
+    }
+
+    /**
+     * Get the groups for each author of the given posts.
+     *
+     * The results are grouped by course and then author id because the groups are
+     * contextually related to the course, e.g. a single author can be part of two different
+     * sets of groups in two different courses.
+     *
+     * @param array $groupedposts List of posts grouped by discussions.
+     * @return array List of groups indexed by forum id and then author id.
+     */
+    private function get_author_groups_from_posts(array $groupedposts) : array {
+        $groupsbyauthorid = [];
+        $authoridsbycourseid = [];
+
+        // Get the unique list of author ids for each course in the grouped
+        // posts. Grouping by course is the largest grouping we can achieve.
+        foreach ($groupedposts as $grouping) {
+            ['forum' => $forum, 'posts' => $posts] = $grouping;
+            $course = $forum->get_course_record();
+            $courseid = $course->id;
+
+            if (!isset($authoridsbycourseid[$courseid])) {
+                $coursemodule = $forum->get_course_module_record();
+                $authoridsbycourseid[$courseid] = [
+                    'groupingid' => $coursemodule->groupingid,
+                    'authorids' => []
+                ];
+            }
+
+            $authorids = array_map(function($post) {
+                return $post->get_author_id();
+            }, $posts);
+
+            foreach ($authorids as $authorid) {
+                $authoridsbycourseid[$courseid]['authorids'][$authorid] = $authorid;
+            }
+        }
+
+        // Load each set of groups per course.
+        foreach ($authoridsbycourseid as $courseid => $values) {
+            ['groupingid' => $groupingid, 'authorids' => $authorids] = $values;
+            $authorgroups = groups_get_all_groups(
+                $courseid,
+                array_keys($authorids),
+                $groupingid,
+                'g.*, gm.id, gm.groupid, gm.userid'
+            );
+
+            if (!isset($groupsbyauthorid[$courseid])) {
+                $groupsbyauthorid[$courseid] = [];
+            }
+
+            foreach ($authorgroups as $group) {
+                // Clean up data returned from groups_get_all_groups.
+                $userid = $group->userid;
+                $groupid = $group->groupid;
+
+                unset($group->userid);
+                unset($group->groupid);
+                $group->id = $groupid;
+
+                if (!isset($groupsbyauthorid[$courseid][$userid])) {
+                    $groupsbyauthorid[$courseid][$userid] = [];
+                }
+
+                $groupsbyauthorid[$courseid][$userid][] = $group;
+            }
+        }
+
+        return $groupsbyauthorid;
+    }
+
+    /**
+     * Get the list of tags for each of the posts. The tags will be returned in an
+     * array indexed by the post id.
+     *
+     * @param post_entity[] $posts The list of posts to load tags for.
+     * @return array Sets of tags indexed by post id.
+     */
+    private function get_tags_from_posts(array $posts) : array {
+        $postids = array_map(function($post) {
+            return $post->get_id();
+        }, $posts);
+        return core_tag_tag::get_items_tags('mod_forum', 'forum_posts', $postids);
+    }
+
+    /**
+     * Get the list of ratings for each post. The ratings are returned in an array
+     * indexed by the post id.
+     *
+     * @param stdClass $user The user viewing the ratings.
+     * @param array $groupedposts List of posts grouped by discussions.
+     * @return array Sets of ratings indexed by post id.
+     */
+    private function get_ratings_from_posts(stdClass $user, array $groupedposts) {
+        $ratingsbypostid = [];
+        $postsdatamapper = $this->legacydatamapperfactory->get_post_data_mapper();
+        $postsbyforum = array_reduce($groupedposts, function($carry, $grouping) {
+            ['forum' => $forum, 'posts' => $posts] = $grouping;
+
+            $forumid = $forum->get_id();
+            if (!isset($carry[$forumid])) {
+                $carry[$forumid] = [
+                    'forum' => $forum,
+                    'posts' => []
+                ];
+            }
+
+            $carry[$forumid]['posts'] = array_merge($carry[$forumid]['posts'], $posts);
+            return $carry;
+        }, []);
+
+        foreach ($postsbyforum as $grouping) {
+            ['forum' => $forum, 'posts' => $posts] = $grouping;
+
+            if (!$forum->has_rating_aggregate()) {
+                continue;
+            }
+
+            $items = $postsdatamapper->to_legacy_objects($posts);
+            $ratingoptions = (object) [
+                'context' => $forum->get_context(),
+                'component' => 'mod_forum',
+                'ratingarea' => 'post',
+                'items' => $items,
+                'aggregate' => $forum->get_rating_aggregate(),
+                'scaleid' => $forum->get_scale(),
+                'userid' => $user->id,
+                'assesstimestart' => $forum->get_assess_time_start(),
+                'assesstimefinish' => $forum->get_assess_time_finish()
+            ];
+
+            $rm = $this->ratingmanager;
+            $items = $rm->get_ratings($ratingoptions);
+
+            foreach ($items as $item) {
+                $ratingsbypostid[$item->id] = empty($item->rating) ? null : $item->rating;
+            }
+        }
+
+        return $ratingsbypostid;
+    }
+
+    /**
+     * Get the read receipt collections for the given viewing user and each forum. The
+     * receipt collections will only be loaded for posts in forums that the user is tracking.
+     *
+     * The receipt collections are returned in an array indexed by the forum ids.
+     *
+     * @param stdClass $user The user viewing the posts.
+     * @param array $groupedposts List of posts grouped by discussions.
+     */
+    private function get_read_receipts_from_posts(stdClass $user, array $groupedposts) {
+        $forumdatamapper = $this->legacydatamapperfactory->get_forum_data_mapper();
+        $trackedforums = [];
+        $trackedpostids = [];
+
+        foreach ($groupedposts as $group) {
+            ['forum' => $forum, 'posts' => $posts] = $group;
+            $forumid = $forum->get_id();
+
+            if (!isset($trackedforums[$forumid])) {
+                $forumrecord = $forumdatamapper->to_legacy_object($forum);
+                $trackedforums[$forumid] = forum_tp_is_tracked($forumrecord, $user);
+            }
+
+            if ($trackedforums[$forumid]) {
+                foreach ($posts as $post) {
+                    $trackedpostids[] = $post->get_id();
+                }
+            }
+        }
+
+        if (empty($trackedpostids)) {
+            return [];
+        }
+
+        // We can just load a single receipt collection for all tracked posts.
+        $receiptvault = $this->vaultfactory->get_post_read_receipt_collection_vault();
+        $readreceiptcollection = $receiptvault->get_from_user_id_and_post_ids($user->id, $trackedpostids);
+        $receiptsbyforumid = [];
+
+        // Assign the collection to all forums that are tracked.
+        foreach ($trackedforums as $forumid => $tracked) {
+            if ($tracked) {
+                $receiptsbyforumid[$forumid] = $readreceiptcollection;
+            }
+        }
+
+        return $receiptsbyforumid;
+    }
+
+    /**
+     * Sort the list of exported posts back into the same order as the given posts.
+     * The ordering of the exported posts can often deviate from the given posts due
+     * to the process of exporting them so we need to sort them back into the order
+     * that the calling code expected.
+     *
+     * @param post_entity[] $posts The posts in the expected order.
+     * @param stdClass[] $exportedposts The list of exported posts in any order.
+     * @return stdClass[] Sorted exported posts.
+     */
+    private function sort_exported_posts(array $posts, array $exportedposts) {
+        $postindexes = [];
+        foreach (array_values($posts) as $index => $post) {
+            $postindexes[$post->get_id()] = $index;
+        }
+
+        $sortedexportedposts = [];
+
+        foreach ($exportedposts as $exportedpost) {
+            $index = $postindexes[$exportedpost->id];
+            $sortedexportedposts[$index] = $exportedpost;
+        }
+
+        return $sortedexportedposts;
+    }
+}
diff --git a/mod/forum/classes/local/container.php b/mod/forum/classes/local/container.php
new file mode 100644 (file)
index 0000000..b8703c8
--- /dev/null
@@ -0,0 +1,152 @@
+<?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/>.
+
+/**
+ * Container class.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\factories\renderer as renderer_factory;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\entity as entity_factory;
+use mod_forum\local\factories\exporter as exporter_factory;
+use mod_forum\local\factories\manager as manager_factory;
+use mod_forum\local\factories\vault as vault_factory;
+use mod_forum\local\factories\builder as builder_factory;
+use mod_forum\local\factories\url as url_factory;
+
+/**
+ * Container class.
+ *
+ * This class provides helper methods with static configurations to get any
+ * of the factories from the "local" namespace.
+ *
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class container {
+    /**
+     * Create the renderer factory.
+     *
+     * @return renderer_factory
+     */
+    public static function get_renderer_factory() : renderer_factory {
+        global $PAGE;
+
+        return new renderer_factory(
+            self::get_legacy_data_mapper_factory(),
+            self::get_exporter_factory(),
+            self::get_vault_factory(),
+            self::get_manager_factory(),
+            self::get_entity_factory(),
+            self::get_builder_factory(),
+            self::get_url_factory(),
+            $PAGE
+        );
+    }
+
+    /**
+     * Create the legacy data mapper factory.
+     *
+     * @return legacy_data_mapper_factory
+     */
+    public static function get_legacy_data_mapper_factory() : legacy_data_mapper_factory {
+        return new legacy_data_mapper_factory();
+    }
+
+    /**
+     * Create the exporter factory.
+     *
+     * @return exporter_factory
+     */
+    public static function get_exporter_factory() : exporter_factory {
+        return new exporter_factory(
+            self::get_legacy_data_mapper_factory(),
+            self::get_manager_factory(),
+            self::get_url_factory()
+        );
+    }
+
+    /**
+     * Create the vault factory.
+     *
+     * @return vault_factory
+     */
+    public static function get_vault_factory() : vault_factory {
+        global $DB;
+
+        return new vault_factory(
+            $DB,
+            self::get_entity_factory(),
+            get_file_storage()
+        );
+    }
+
+    /**
+     * Create the manager factory.
+     *
+     * @return manager_factory
+     */
+    public static function get_manager_factory() : manager_factory {
+        return new manager_factory(
+            self::get_legacy_data_mapper_factory()
+        );
+    }
+
+    /**
+     * Create the entity factory.
+     *
+     * @return entity_factory
+     */
+    public static function get_entity_factory() : entity_factory {
+        return new entity_factory();
+    }
+
+    /**
+     * Create the builder factory.
+     *
+     * @return builder_factory
+     */
+    public static function get_builder_factory() : builder_factory {
+        global $PAGE;
+
+        return new builder_factory(
+            self::get_legacy_data_mapper_factory(),
+            self::get_exporter_factory(),
+            self::get_vault_factory(),
+            self::get_manager_factory(),
+            $PAGE->get_renderer('mod_forum')
+        );
+    }
+
+    /**
+     * Create the URL factory.
+     *
+     * @return url_factory
+     */
+    public static function get_url_factory() : url_factory {
+        return new url_factory(
+            self::get_legacy_data_mapper_factory()
+        );
+    }
+}
diff --git a/mod/forum/classes/local/data_mappers/legacy/author.php b/mod/forum/classes/local/data_mappers/legacy/author.php
new file mode 100644 (file)
index 0000000..a8ddb12
--- /dev/null
@@ -0,0 +1,72 @@
+<?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/>.
+
+/**
+ * Author data mapper.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\data_mappers\legacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\author as author_entity;
+use stdClass;
+
+/**
+ * Convert an author entity into an stdClass.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class author {
+    /**
+     * Convert a list of author entities into stdClasses.
+     *
+     * @param author_entity[] $authors The authors to convert.
+     * @return stdClass[]
+     */
+    public function to_legacy_objects(array $authors) : array {
+        return array_map(function(author_entity $author) {
+            return (object) [
+                'id' => $author->get_id(),
+                'picture' => $author->get_picture_item_id(),
+                'firstname' => $author->get_first_name(),
+                'lastname' => $author->get_last_name(),
+                'fullname' => $author->get_full_name(),
+                'email' => $author->get_email(),
+                'middlename' => $author->get_middle_name(),
+                'firstnamephonetic' => $author->get_first_name_phonetic(),
+                'lastnamephonetic' => $author->get_last_name_phonetic(),
+                'alternatename' => $author->get_alternate_name(),
+                'imagealt' => $author->get_image_alt()
+            ];
+        }, $authors);
+    }
+
+    /**
+     * Convert an author entity into an stdClass.
+     *
+     * @param author_entity $author The author to convert.
+     * @return stdClass
+     */
+    public function to_legacy_object(author_entity $author) : stdClass {
+        return $this->to_legacy_objects([$author])[0];
+    }
+}
diff --git a/mod/forum/classes/local/data_mappers/legacy/discussion.php b/mod/forum/classes/local/data_mappers/legacy/discussion.php
new file mode 100644 (file)
index 0000000..cbbf4c1
--- /dev/null
@@ -0,0 +1,74 @@
+<?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/>.
+
+/**
+ * Discussion data mapper.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\data_mappers\legacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use stdClass;
+
+/**
+ * Convert a discussion entity into an stdClass.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion {
+    /**
+     * Convert a list of discussion entities into stdClasses.
+     *
+     * @param discussion_entity[] $authors The authors to convert.
+     * @return stdClass[]
+     */
+    public function to_legacy_objects(array $discussions) : array {
+        return array_map(function(discussion_entity $discussion) {
+            return (object) [
+                'id' => $discussion->get_id(),
+                'course' => $discussion->get_course_id(),
+                'forum' => $discussion->get_forum_id(),
+                'name' => $discussion->get_name(),
+                'firstpost' => $discussion->get_first_post_id(),
+                'userid' => $discussion->get_user_id(),
+                'groupid' => $discussion->get_group_id(),
+                'assessed' => $discussion->is_assessed(),
+                'timemodified' => $discussion->get_time_modified(),
+                'usermodified' => $discussion->get_user_modified(),
+                'timestart' => $discussion->get_time_start(),
+                'timeend' => $discussion->get_time_end(),
+                'pinned' => $discussion->is_pinned()
+            ];
+        }, $discussions);
+    }
+
+    /**
+     * Convert a discussion entity into an stdClass.
+     *
+     * @param discussion_entity $discussion The discussion to convert.
+     * @return stdClass
+     */
+    public function to_legacy_object(discussion_entity $discussion) : stdClass {
+        return $this->to_legacy_objects([$discussion])[0];
+    }
+}
diff --git a/mod/forum/classes/local/data_mappers/legacy/forum.php b/mod/forum/classes/local/data_mappers/legacy/forum.php
new file mode 100644 (file)
index 0000000..bc89b2c
--- /dev/null
@@ -0,0 +1,86 @@
+<?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/>.
+
+/**
+ * Forum data mapper.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\data_mappers\legacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\forum as forum_entity;
+use stdClass;
+
+/**
+ * Convert a forum entity into an stdClass.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class forum {
+    /**
+     * Convert a list of forum entities into stdClasses.
+     *
+     * @param forum_entity[] $forums The forums to convert.
+     * @return stdClass[]
+     */
+    public function to_legacy_objects(array $forums) : array {
+        return array_map(function(forum_entity $forum) {
+            return (object) [
+                'id' => $forum->get_id(),
+                'course' => $forum->get_course_id(),
+                'type' => $forum->get_type(),
+                'name' => $forum->get_name(),
+                'intro' => $forum->get_intro(),
+                'introformat' => $forum->get_intro_format(),
+                'assessed' => $forum->get_rating_aggregate(),
+                'assesstimestart' => $forum->get_assess_time_start(),
+                'assesstimefinish' => $forum->get_assess_time_finish(),
+                'scale' => $forum->get_scale(),
+                'maxbytes' => $forum->get_max_bytes(),
+                'maxattachments' => $forum->get_max_attachments(),
+                'forcesubscribe' => $forum->get_subscription_mode(),
+                'trackingtype' => $forum->get_tracking_type(),
+                'rsstype' => $forum->get_rss_type(),
+                'rssarticles' => $forum->get_rss_articles(),
+                'timemodified' => $forum->get_time_modified(),
+                'warnafter' => $forum->get_warn_after(),
+                'blockafter' => $forum->get_block_after(),
+                'blockperiod' => $forum->get_block_period(),
+                'completiondiscussions' => $forum->get_completion_discussions(),
+                'completionreplies' => $forum->get_completion_replies(),
+                'completionposts' => $forum->get_completion_posts(),
+                'displaywordcount' => $forum->should_display_word_count(),
+                'lockdiscussionafter' => $forum->get_lock_discussions_after()
+            ];
+        }, $forums);
+    }
+
+    /**
+     * Convert a forum entity into an stdClass.
+     *
+     * @param forum_entity $forum The forum to convert.
+     * @return stdClass
+     */
+    public function to_legacy_object(forum_entity $forum) : stdClass {
+        return $this->to_legacy_objects([$forum])[0];
+    }
+}
diff --git a/mod/forum/classes/local/data_mappers/legacy/post.php b/mod/forum/classes/local/data_mappers/legacy/post.php
new file mode 100644 (file)
index 0000000..5cbd2d4
--- /dev/null
@@ -0,0 +1,76 @@
+<?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/>.
+
+/**
+ * Post data mapper.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\data_mappers\legacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\post as post_entity;
+use stdClass;
+
+/**
+ * Convert a post entity into an stdClass.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class post {
+    /**
+     * Convert a list of post entities into stdClasses.
+     *
+     * @param post_entity[] $posts The posts to convert.
+     * @return stdClass[]
+     */
+    public function to_legacy_objects(array $posts) : array {
+        return array_map(function(post_entity $post) {
+            return (object) [
+                'id' => $post->get_id(),
+                'discussion' => $post->get_discussion_id(),
+                'parent' => $post->get_parent_id(),
+                'userid' => $post->get_author_id(),
+                'created' => $post->get_time_created(),
+                'modified' => $post->get_time_modified(),
+                'mailed' => $post->has_been_mailed(),
+                'subject' => $post->get_subject(),
+                'message' => $post->get_message(),
+                'messageformat' => $post->get_message_format(),
+                'messagetrust' => $post->is_message_trusted(),
+                'attachment' => $post->has_attachments(),
+                'totalscore' => $post->get_total_score(),
+                'mailnow' => $post->should_mail_now(),
+                'deleted' => $post->is_deleted()
+            ];
+        }, $posts);
+    }
+
+    /**
+     * Convert a post entity into an stdClass.
+     *
+     * @param post_entity $post The post to convert.
+     * @return stdClass
+     */
+    public function to_legacy_object(post_entity $post) : stdClass {
+        return $this->to_legacy_objects([$post])[0];
+    }
+}
diff --git a/mod/forum/classes/local/entities/author.php b/mod/forum/classes/local/entities/author.php
new file mode 100644 (file)
index 0000000..d2ed541
--- /dev/null
@@ -0,0 +1,198 @@
+<?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/>.
+
+/**
+ * Author class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Author class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class author {
+    /** @var int $id ID */
+    private $id;
+    /** @var int $pictureitemid Picture item id */
+    private $pictureitemid;
+    /** @var string $firstname First name */
+    private $firstname;
+    /** @var string $lastname Last name */
+    private $lastname;
+    /** @var string $fullname Full name */
+    private $fullname;
+    /** @var string $email Email */
+    private $email;
+    /** @var string $middlename Middle name */
+    private $middlename;
+    /** @var string $firstnamephonetic Phonetic spelling of first name */
+    private $firstnamephonetic;
+    /** @var string $lastnamephonetic Phonetic spelling of last name */
+    private $lastnamephonetic;
+    /** @var string $alternatename Altername name */
+    private $alternatename;
+    /** @var string $imagealt Image alt */
+    private $imagealt;
+
+    /**
+     * Constructor.
+     *
+     * @param int $id ID
+     * @param int $pictureitemid Picture item id
+     * @param string $firstname First name
+     * @param string $lastname Last name
+     * @param string $fullname Full name
+     * @param string $email Email
+     * @param string|null $middlename Middle name
+     * @param string|null $firstnamephonetic Phonetic spelling of first name
+     * @param string|null $lastnamephonetic Phonetic spelling of last name
+     * @param string|null $alternatename Altername name
+     * @param string|null $imagealt Image alt
+     */
+    public function __construct(
+        int $id,
+        int $pictureitemid,
+        string $firstname,
+        string $lastname,
+        string $fullname,
+        string $email,
+        string $middlename = null,
+        string $firstnamephonetic = null,
+        string $lastnamephonetic = null,
+        string $alternatename = null,
+        string $imagealt = null
+    ) {
+        $this->id = $id;
+        $this->pictureitemid = $pictureitemid;
+        $this->firstname = $firstname;
+        $this->lastname = $lastname;
+        $this->fullname = $fullname;
+        $this->email = $email;
+        $this->middlename = $middlename;
+        $this->firstnamephonetic = $firstnamephonetic;
+        $this->lastnamephonetic = $lastnamephonetic;
+        $this->alternatename = $alternatename;
+        $this->imagealt = $imagealt;
+    }
+
+    /**
+     * Return the id.
+     *
+     * @return int
+     */
+    public function get_id() : int {
+        return $this->id;
+    }
+
+    /**
+     * Return the picture item id.
+     *
+     * @return int
+     */
+    public function get_picture_item_id() : int {
+        return $this->pictureitemid;
+    }
+
+    /**
+     * Return the first name.
+     *
+     * @return string
+     */
+    public function get_first_name() : string {
+        return $this->firstname;
+    }
+
+    /**
+     * Return the last name.
+     *
+     * @return string
+     */
+    public function get_last_name() : string {
+        return $this->lastname;
+    }
+
+    /**
+     * Return the full name.
+     *
+     * @return string
+     */
+    public function get_full_name() : string {
+        return $this->fullname;
+    }
+
+    /**
+     * Return the email.
+     *
+     * @return string
+     */
+    public function get_email() : string {
+        return $this->email;
+    }
+
+    /**
+     * Return the middle name.
+     *
+     * @return string|null
+     */
+    public function get_middle_name() : ?string {
+        return $this->middlename;
+    }
+
+    /**
+     * Return the first name phonetic.
+     *
+     * @return string|null
+     */
+    public function get_first_name_phonetic() : ?string {
+        return $this->firstnamephonetic;
+    }
+
+    /**
+     * Return the last name phonetic.
+     *
+     * @return string|null
+     */
+    public function get_last_name_phonetic() : ?string {
+        return $this->lastnamephonetic;
+    }
+
+    /**
+     * Return the alternate name.
+     *
+     * @return string|null
+     */
+    public function get_alternate_name() : ?string {
+        return $this->alternatename;
+    }
+
+    /**
+     * Return the image alt.
+     *
+     * @return string|null
+     */
+    public function get_image_alt() : ?string {
+        return $this->imagealt;
+    }
+}
diff --git a/mod/forum/classes/local/entities/discussion.php b/mod/forum/classes/local/entities/discussion.php
new file mode 100644 (file)
index 0000000..68c3b1d
--- /dev/null
@@ -0,0 +1,290 @@
+<?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/>.
+
+/**
+ * Discussion class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\post as post_entity;
+
+/**
+ * Discussion class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion {
+    /** @var int $id ID */
+    private $id;
+    /** @var int $courseid Course id */
+    private $courseid;
+    /** @var int $forumid Forum id */
+    private $forumid;
+    /** @var string $name Discussion name */
+    private $name;
+    /** @var int $firstpostid Id of the first post in the discussion */
+    private $firstpostid;
+    /** @var int $userid Id of the user that created the discussion */
+    private $userid;
+    /** @var int $groupid Group id if it's a group dicussion */
+    private $groupid;
+    /** @var bool $assessed Is the discussion assessed? */
+    private $assessed;
+    /** @var int $timemodified Timestamp for last modification to the discussion */
+    private $timemodified;
+    /** @var int $usermodified Id of user that last modified the discussion */
+    private $usermodified;
+    /** @var int $timestart Start time for the discussion */
+    private $timestart;
+    /** @var int $timeend End time for the discussion */
+    private $timeend;
+    /** @var bool $pinned Is the discussion pinned? */
+    private $pinned;
+
+    /**
+     * Constructor.
+     *
+     * @param int $id ID
+     * @param int $courseid Course id
+     * @param int $forumid Forum id
+     * @param string $name Discussion name
+     * @param int $firstpostid Id of the first post in the discussion
+     * @param int $userid Id of the user that created the discussion
+     * @param int $groupid Group id if it's a group dicussion
+     * @param bool $assessed Is the discussion assessed?
+     * @param int $timemodified Timestamp for last modification to the discussion
+     * @param int $usermodified Id of user that last modified the discussion
+     * @param int $timestart Start time for the discussion
+     * @param int $timeend End time for the discussion
+     * @param bool $pinned Is the discussion pinned?
+     */
+    public function __construct(
+        int $id,
+        int $courseid,
+        int $forumid,
+        string $name,
+        int $firstpostid,
+        int $userid,
+        int $groupid,
+        bool $assessed,
+        int $timemodified,
+        int $usermodified,
+        int $timestart,
+        int $timeend,
+        bool $pinned
+    ) {
+        $this->id = $id;
+        $this->courseid = $courseid;
+        $this->forumid = $forumid;
+        $this->name = $name;
+        $this->firstpostid = $firstpostid;
+        $this->userid = $userid;
+        $this->groupid = $groupid;
+        $this->assessed = $assessed;
+        $this->timemodified = $timemodified;
+        $this->usermodified = $usermodified;
+        $this->timestart = $timestart;
+        $this->timeend = $timeend;
+        $this->pinned = $pinned;
+    }
+
+    /**
+     * Get the discussion id.
+     *
+     * @return int
+     */
+    public function get_id() : int {
+        return $this->id;
+    }
+
+    /**
+     * Get the course id.
+     *
+     * @return int
+     */
+    public function get_course_id() : int {
+        return $this->courseid;
+    }
+
+    /**
+     * Get the forum id.
+     *
+     * @return int
+     */
+    public function get_forum_id() : int {
+        return $this->forumid;
+    }
+
+    /**
+     * Get the name of the discussion.
+     *
+     * @return string
+     */
+    public function get_name() : string {
+        return $this->name;
+    }
+
+    /**
+     * Get the id of the fist post in the discussion.
+     *
+     * @return int
+     */
+    public function get_first_post_id() : int {
+        return $this->firstpostid;
+    }
+
+    /**
+     * Get the id of the user that created the discussion.
+     *
+     * @return int
+     */
+    public function get_user_id() : int {
+        return $this->userid;
+    }
+
+    /**
+     * Get the id of the group that this discussion belongs to.
+     *
+     * @return int
+     */
+    public function get_group_id() : int {
+        return $this->groupid;
+    }
+
+    /**
+     * Check if this discussion is assessed.
+     *
+     * @return bool
+     */
+    public function is_assessed() : bool {
+        return $this->assessed;
+    }
+
+    /**
+     * Get the timestamp for when this discussion was last modified.
+     *
+     * @return int
+     */
+    public function get_time_modified() : int {
+        return $this->timemodified;
+    }
+
+    /**
+     * Get the id of the user that last modified this discussion.
+     *
+     * @return int
+     */
+    public function get_user_modified() : int {
+        return $this->usermodified;
+    }
+
+    /**
+     * Get the start time of this discussion. Returns zero if the discussion
+     * has no designated start time.
+     *
+     * @return int
+     */
+    public function get_time_start() : int {
+        return $this->timestart;
+    }
+
+    /**
+     * Get the end time of this discussion. Returns zero if the discussion
+     * has no designated end time.
+     *
+     * @return int
+     */
+    public function get_time_end() : int {
+        return $this->timeend;
+    }
+
+    /**
+     * Check if this discussion is pinned.
+     *
+     * @return bool
+     */
+    public function is_pinned() : bool {
+        return $this->pinned;
+    }
+
+    /**
+     * Check if the given post is the first post in this discussion.
+     *
+     * @param post_entity $post The post to check
+     * @return bool
+     */
+    public function is_first_post(post_entity $post) : bool {
+        return $this->get_first_post_id() === $post->get_id();
+    }
+
+    /**
+     * Check if the discussion has started yet.
+     *
+     * @return bool
+     */
+    public function has_started() : bool {
+        $startime = $this->get_time_start();
+        return empty($startime) || $startime < time();
+    }
+
+    /**
+     * Check if the discussion has ended.
+     *
+     * @return bool
+     */
+    public function has_ended() : bool {
+        $endtime = $this->get_time_end();
+        return !empty($endtime) && $endtime >= time();
+    }
+
+    /**
+     * Check if the discussion belongs to a group.
+     *
+     * @return bool
+     */
+    public function has_group() : bool {
+        return $this->get_group_id() > 0;
+    }
+
+    /**
+     * Check if the discussion is timed.
+     *
+     * @return bool
+     */
+    public function is_timed_discussion() : bool {
+        global $CFG;
+
+        return !empty($CFG->forum_enabletimedposts) &&
+              ($this->get_time_start() || $this->get_time_end());
+    }
+
+    /**
+     * Check if the timed discussion is visible.
+     *
+     * @return bool
+     */
+    public function is_timed_discussion_visible() : bool {
+        return !$this->is_timed_discussion() || ($this->has_started() && !$this->has_ended());
+    }
+}
diff --git a/mod/forum/classes/local/entities/discussion_summary.php b/mod/forum/classes/local/entities/discussion_summary.php
new file mode 100644 (file)
index 0000000..facf5ff
--- /dev/null
@@ -0,0 +1,104 @@
+<?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/>.
+
+/**
+ * Discussion summary class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\entities\author as author_entity;
+
+/**
+ * Discussion summary class.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion_summary {
+    /** @var discussion_entity $discussion The discussion being summarised */
+    private $discussion;
+    /** @var author_entity $firstpostauthor Author of the first post in the discussion */
+    private $firstpostauthor;
+    /** @var post_entity $firstpost First post in the discussion */
+    private $firstpost;
+    /** @var author_entity $latestpostauthor Author of the last post in the discussion */
+    private $latestpostauthor;
+
+    /**
+     * Constructor.
+     *
+     * @param discussion_entity $discussion The discussion being summarised
+     * @param post_entity $firstpost First post in the discussion
+     * @param author_entity $firstpostauthor Author of the first post in the discussion
+     * @param author_entity $latestpostauthor Author of the last post in the discussion
+     */
+    public function __construct(
+        discussion_entity $discussion,
+        post_entity $firstpost,
+        author_entity $firstpostauthor,
+        author_entity $latestpostauthor
+    ) {
+        $this->discussion = $discussion;
+        $this->firstpostauthor = $firstpostauthor;
+        $this->firstpost = $firstpost;
+        $this->latestpostauthor = $latestpostauthor;
+    }
+
+    /**
+     * Get the discussion entity.
+     *
+     * @return discussion_entity
+     */
+    public function get_discussion() : discussion_entity {
+        return $this->discussion;
+    }
+
+    /**
+     * Get the author entity for the first post.
+     *
+     * @return author_entity
+     */
+    public function get_first_post_author() : author_entity {
+        return $this->firstpostauthor;
+    }
+
+    /**
+     * Get the author entity for the last post.
+     *
+     * @return author_entity
+     */
+    public function get_latest_post_author() : author_entity {
+        return $this->latestpostauthor;
+    }
+
+    /**
+     * Get the post entity for the first post.
+     *
+     * @return post_entity
+     */
+    public function get_first_post() : post_entity {
+        return $this->firstpost;
+    }
+}
diff --git a/mod/forum/classes/local/entities/forum.php b/mod/forum/classes/local/entities/forum.php
new file mode 100644 (file)
index 0000000..fe9e939
--- /dev/null
@@ -0,0 +1,549 @@
+<?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/>.
+
+/**
+ * Forum class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once($CFG->dirroot . '/rating/lib.php');
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use context;
+use stdClass;
+
+/**
+ * Forum class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class forum {
+    /** @var context $context The forum module context */
+    private $context;
+    /** @var stdClass $coursemodule The forum course module record */
+    private $coursemodule;
+    /** @var stdClass $course The forum course record */
+    private $course;
+    /** @var int $effectivegroupmode The effective group mode */
+    private $effectivegroupmode;
+    /** @var int $id ID */
+    private $id;
+    /** @var int $courseid Id of the course this forum is in */
+    private $courseid;
+    /** @var string $type The forum type, e.g. single, qanda, etc */
+    private $type;
+    /** @var string $name Name of the forum */
+    private $name;
+    /** @var string $intro Intro text */
+    private $intro;
+    /** @var int $introformat Format of the intro text */
+    private $introformat;
+    /** @var int $assessed The forum rating aggregate */
+    private $assessed;
+    /** @var int $assesstimestart Timestamp to begin assessment */
+    private $assesstimestart;
+    /** @var int $assesstimefinish Timestamp to end assessment */
+    private $assesstimefinish;
+    /** @var int $scale The rating scale */
+    private $scale;
+    /** @var int $maxbytes Maximum attachment size */
+    private $maxbytes;
+    /** @var int $maxattachments Maximum number of attachments */
+    private $maxattachments;
+    /** @var int $forcesubscribe Does the forum force users to subscribe? */
+    private $forcesubscribe;
+    /** @var int $trackingtype Tracking type */
+    private $trackingtype;
+    /** @var int $rsstype RSS type */
+    private $rsstype;
+    /** @var int $rssarticles RSS articles */
+    private $rssarticles;
+    /** @var int $timemodified Timestamp when the forum was last modified */
+    private $timemodified;
+    /** @var int $warnafter Warn after */
+    private $warnafter;
+    /** @var int $blockafter Block after */
+    private $blockafter;
+    /** @var int $blockperiod Block period */
+    private $blockperiod;
+    /** @var int $completiondiscussions Completion discussions */
+    private $completiondiscussions;
+    /** @var int $completionreplies Completion replies */
+    private $completionreplies;
+    /** @var int $completionposts Completion posts */
+    private $completionposts;
+    /** @var bool $displaywordcounts Should display word counts in posts */
+    private $displaywordcounts;
+    /** @var bool $lockdiscussionafter Timestamp after which discussions should be locked */
+    private $lockdiscussionafter;
+
+    /**
+     * Constructor
+     *
+     * @param context $context The forum module context
+     * @param stdClass $coursemodule The forum course module record
+     * @param stdClass $course The forum course record
+     * @param int $effectivegroupmode The effective group mode
+     * @param int $id ID
+     * @param int $courseid Id of the course this forum is in
+     * @param string $type The forum type, e.g. single, qanda, etc
+     * @param string $name Name of the forum
+     * @param string $intro Intro text
+     * @param int $introformat Format of the intro text
+     * @param int $assessed The forum rating aggregate
+     * @param int $assesstimestart Timestamp to begin assessment
+     * @param int $assesstimefinish Timestamp to end assessment
+     * @param int $scale The rating scale
+     * @param int $maxbytes Maximum attachment size
+     * @param int $maxattachments Maximum number of attachments
+     * @param int $forcesubscribe Does the forum force users to subscribe?
+     * @param int $trackingtype Tracking type
+     * @param int $rsstype RSS type
+     * @param int $rssarticles RSS articles
+     * @param int $timemodified Timestamp when the forum was last modified
+     * @param int $warnafter Warn after
+     * @param int $blockafter Block after
+     * @param int $blockperiod Block period
+     * @param int $completiondiscussions Completion discussions
+     * @param int $completionreplies Completion replies
+     * @param int $completionposts Completion posts
+     * @param bool $displaywordcount Should display word counts in posts
+     * @param int $lockdiscussionafter Timestamp after which discussions should be locked
+     */
+    public function __construct(
+        context $context,
+        stdClass $coursemodule,
+        stdClass $course,
+        int $effectivegroupmode,
+        int $id,
+        int $courseid,
+        string $type,
+        string $name,
+        string $intro,
+        int $introformat,
+        int $assessed,
+        int $assesstimestart,
+        int $assesstimefinish,
+        int $scale,
+        int $maxbytes,
+        int $maxattachments,
+        int $forcesubscribe,
+        int $trackingtype,
+        int $rsstype,
+        int $rssarticles,
+        int $timemodified,
+        int $warnafter,
+        int $blockafter,
+        int $blockperiod,
+        int $completiondiscussions,
+        int $completionreplies,
+        int $completionposts,
+        bool $displaywordcount,
+        int $lockdiscussionafter
+    ) {
+        $this->context = $context;
+        $this->coursemodule = $coursemodule;
+        $this->course = $course;
+        $this->effectivegroupmode = $effectivegroupmode;
+        $this->id = $id;
+        $this->courseid = $courseid;
+        $this->type = $type;
+        $this->name = $name;
+        $this->intro = $intro;
+        $this->introformat = $introformat;
+        $this->assessed = $assessed;
+        $this->assesstimestart = $assesstimestart;
+        $this->assesstimefinish = $assesstimefinish;
+        $this->scale = $scale;
+        $this->maxbytes = $maxbytes;
+        $this->maxattachments = $maxattachments;
+        $this->forcesubscribe = $forcesubscribe;
+        $this->trackingtype = $trackingtype;
+        $this->rsstype = $rsstype;
+        $this->rssarticles = $rssarticles;
+        $this->timemodified = $timemodified;
+        $this->warnafter = $warnafter;
+        $this->blockafter = $blockafter;
+        $this->blockperiod = $blockperiod;
+        $this->completiondiscussions = $completiondiscussions;
+        $this->completionreplies = $completionreplies;
+        $this->completionposts = $completionposts;
+        $this->displaywordcount = $displaywordcount;
+        $this->lockdiscussionafter = $lockdiscussionafter;
+    }
+
+    /**
+     * Get the forum module context.
+     *
+     * @return context
+     */
+    public function get_context() : context {
+        return $this->context;
+    }
+
+    /**
+     * Get the forum course module record
+     *
+     * @return stdClass
+     */
+    public function get_course_module_record() : stdClass {
+        return $this->coursemodule;
+    }
+
+    /**
+     * Get the effective group mode.
+     *
+     * @return int
+     */
+    public function get_effective_group_mode() : int {
+        return $this->effectivegroupmode;
+    }
+
+    /**
+     * Check if the forum is set to group mode.
+     *
+     * @return bool
+     */
+    public function is_in_group_mode() : bool {
+        return $this->get_effective_group_mode() !== NOGROUPS;
+    }
+
+    /**
+     * Get the course record.
+     *
+     * @return stdClass
+     */
+    public function get_course_record() : stdClass {
+        return $this->course;
+    }
+
+    /**
+     * Get the forum id.
+     *
+     * @return int
+     */
+    public function get_id() : int {
+        return $this->id;
+    }
+
+    /**
+     * Get the id of the course that the forum belongs to.
+     *
+     * @return int
+     */
+    public function get_course_id() : int {
+        return $this->courseid;
+    }
+
+    /**
+     * Get the forum type.
+     *
+     * @return string
+     */
+    public function get_type() : string {
+        return $this->type;
+    }
+
+    /**
+     * Get the forum name.
+     *
+     * @return string
+     */
+    public function get_name() : string {
+        return $this->name;
+    }
+
+    /**
+     * Get the forum intro text.
+     *
+     * @return string
+     */
+    public function get_intro() : string {
+        return $this->intro;
+    }
+
+    /**
+     * Get the forum intro text format.
+     *
+     * @return int
+     */
+    public function get_intro_format() : int {
+        return $this->introformat;
+    }
+
+    /**
+     * Get the rating aggregate.
+     *
+     * @return int
+     */
+    public function get_rating_aggregate() : int {
+        return $this->assessed;
+    }
+
+    /**
+     * Does the forum have a rating aggregate?
+     *
+     * @return bool
+     */
+    public function has_rating_aggregate() : bool {
+        return $this->get_rating_aggregate() != RATING_AGGREGATE_NONE;
+    }
+
+    /**
+     * Get the timestamp for when the assessment period begins.
+     *
+     * @return int
+     */
+    public function get_assess_time_start() : int {
+        return $this->assesstimestart;
+    }
+
+    /**
+     * Get the timestamp for when the assessment period ends.
+     *
+     * @return int
+     */
+    public function get_assess_time_finish() : int {
+        return $this->assesstimefinish;
+    }
+
+    /**
+     * Get the rating scale.
+     *
+     * @return int
+     */
+    public function get_scale() : int {
+        return $this->scale;
+    }
+
+    /**
+     * Get the maximum bytes.
+     *
+     * @return int
+     */
+    public function get_max_bytes() : int {
+        return $this->maxbytes;
+    }
+
+    /**
+     * Get the maximum number of attachments.
+     *
+     * @return int
+     */
+    public function get_max_attachments() : int {
+        return $this->maxattachments;
+    }
+
+    /**
+     * Get the subscription mode.
+     *
+     * @return int
+     */
+    public function get_subscription_mode() : int {
+        return $this->forcesubscribe;
+    }
+
+    /**
+     * Is the subscription mode set to optional.
+     *
+     * @return bool
+     */
+    public function is_subscription_optional() : bool {
+        return $this->get_subscription_mode() === FORUM_CHOOSESUBSCRIBE;
+    }
+
+    /**
+     * Is the subscription mode set to forced.
+     *
+     * @return bool
+     */
+    public function is_subscription_forced() : bool {
+        return $this->get_subscription_mode() === FORUM_FORCESUBSCRIBE;
+    }
+
+    /**
+     * Is the subscription mode set to automatic.
+     *
+     * @return bool
+     */
+    public function is_subscription_automatic() : bool {
+        return $this->get_subscription_mode() === FORUM_INITIALSUBSCRIBE;
+    }
+
+    /**
+     * Is the subscription mode set to disabled.
+     *
+     * @return bool
+     */
+    public function is_subscription_disabled() : bool {
+        return $this->get_subscription_mode() === FORUM_DISALLOWSUBSCRIBE;
+    }
+
+    /**
+     * Get the tracking type.
+     *
+     * @return int
+     */
+    public function get_tracking_type() : int {
+        return $this->trackingtype;
+    }
+
+    /**
+     * Get the RSS type.
+     *
+     * @return int
+     */
+    public function get_rss_type() : int {
+        return $this->rsstype;
+    }
+
+    /**
+     * Get the RSS articles.
+     *
+     * @return int
+     */
+    public function get_rss_articles() : int {
+        return $this->rssarticles;
+    }
+
+    /**
+     * Get the timestamp for when the forum was last modified.
+     *
+     * @return int
+     */
+    public function get_time_modified() : int {
+        return $this->timemodified;
+    }
+
+    /**
+     * Get warn after.
+     *
+     * @return int
+     */
+    public function get_warn_after() : int {
+        return $this->warnafter;
+    }
+
+    /**
+     * Get block after.
+     *
+     * @return int
+     */
+    public function get_block_after() : int {
+        return $this->blockafter;
+    }
+
+    /**
+     * Get the block period.
+     *
+     * @return int
+     */
+    public function get_block_period() : int {
+        return $this->blockperiod;
+    }
+
+    /**
+     * Does the forum have blocking enabled?
+     *
+     * @return bool
+     */
+    public function has_blocking_enabled() : bool {
+        return !empty($this->get_block_after()) && !empty($this->get_block_period());
+    }
+
+    /**
+     * Get the completion discussions.
+     *
+     * @return int
+     */
+    public function get_completion_discussions() : int {
+        return $this->completiondiscussions;
+    }
+
+    /**
+     * Get the completion replies.
+     *
+     * @return int
+     */
+    public function get_completion_replies() : int {
+        return $this->completionreplies;
+    }
+
+    /**
+     * Get the completion posts.
+     *
+     * @return int
+     */
+    public function get_completion_posts() : int {
+        return $this->completionposts;
+    }
+
+    /**
+     * Should the word counts be shown in the posts?
+     *
+     * @return bool
+     */
+    public function should_display_word_count() : bool {
+        return $this->displaywordcount;
+    }
+
+    /**
+     * Get the timestamp after which the discussion should be locked.
+     *
+     * @return int
+     */
+    public function get_lock_discussions_after() : int {
+        return $this->lockdiscussionafter;
+    }
+
+    /**
+     * Does the forum have a discussion locking timestamp?
+     *
+     * @return bool
+     */
+    public function has_lock_discussions_after() : bool {
+        return !empty($this->get_lock_discussions_after());
+    }
+
+    /**
+     * Is the discussion locked?
+     *
+     * @param discussion_entity $discussion The discussion to check
+     * @return bool
+     */
+    public function is_discussion_locked(discussion_entity $discussion) : bool {
+        if (!$this->has_lock_discussions_after()) {
+            return false;
+        }
+
+        if ($this->get_type() === 'single') {
+            // It does not make sense to lock a single discussion forum.
+            return false;
+        }
+
+        return (($discussion->get_time_modified() + $this->get_lock_discussions_after()) < time());
+    }
+}
diff --git a/mod/forum/classes/local/entities/post.php b/mod/forum/classes/local/entities/post.php
new file mode 100644 (file)
index 0000000..6c394a6
--- /dev/null
@@ -0,0 +1,284 @@
+<?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/>.
+
+/**
+ * Post class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+use stdClass;
+
+/**
+ * Post class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class post {
+    /** @var int $id ID */
+    private $id;
+    /** @var int $discussionid The id of the discussion this post belongs to */
+    private $discussionid;
+    /** @var int $parentid The id of the post that this post is replying to. Zero if it isn't a reply. */
+    private $parentid;
+    /** @var int $authorid The id of user who authored the post */
+    private $authorid;
+    /** @var int $timecreated Timestamp for when the post was created */
+    private $timecreated;
+    /** @var int $timemodified Timestamp for when the post last modified */
+    private $timemodified;
+    /** @var bool $mailed If the post has been mailed */
+    private $mailed;
+    /** @var string $subject Post subject */
+    private $subject;
+    /** @var string $message Post message */
+    private $message;
+    /** @var int $messageformat Format of the post message */
+    private $messageformat;
+    /** @var bool $messagetrust Is this a trusted message, i.e. created by a trusted user. */
+    private $messagetrust;
+    /** @var bool $hasattachments Does the post have attachments */
+    private $hasattachments;
+    /** @var int $totalscore Total score */
+    private $totalscore;
+    /** @var bool $mailnow Should this post be mailed immediately */
+    private $mailnow;
+    /** @var bool $deleted Is the post deleted */
+    private $deleted;
+
+    /**
+     * Constructor.
+     *
+     * @param int $id ID
+     * @param int $discussionid The id of the discussion this post belongs to
+     * @param int $parentid The id of the post that this post is replying to. Zero if it isn't a reply.
+     * @param int $authorid The id of user who authored the post
+     * @param int $timecreated Timestamp for when the post was created
+     * @param int $timemodified Timestamp for when the post last modified
+     * @param bool $mailed If the post has been mailed
+     * @param string $subject Post subject
+     * @param string $message Post message
+     * @param int $messageformat Format of the post message
+     * @param bool $messagetrust Is this a trusted message, i.e. created by a trusted user.
+     * @param bool $hasattachments Does the post have attachments
+     * @param int $totalscore Total score
+     * @param bool $mailnow Should this post be mailed immediately
+     * @param bool $deleted Is the post deleted
+     */
+    public function __construct(
+        int $id,
+        int $discussionid,
+        int $parentid,
+        int $authorid,
+        int $timecreated,
+        int $timemodified,
+        bool $mailed,
+        string $subject,
+        string $message,
+        int $messageformat,
+        bool $messagetrust,
+        bool $hasattachments,
+        int $totalscore,
+        bool $mailnow,
+        bool $deleted
+    ) {
+        $this->id = $id;
+        $this->discussionid = $discussionid;
+        $this->parentid = $parentid;
+        $this->authorid = $authorid;
+        $this->timecreated = $timecreated;
+        $this->timemodified = $timemodified;
+        $this->mailed = $mailed;
+        $this->subject = $subject;
+        $this->message = $message;
+        $this->messageformat = $messageformat;
+        $this->messagetrust = $messagetrust;
+        $this->hasattachments = $hasattachments;
+        $this->totalscore = $totalscore;
+        $this->mailnow = $mailnow;
+        $this->deleted = $deleted;
+    }
+
+    /**
+     * Get the post id.
+     *
+     * @return int
+     */
+    public function get_id() : int {
+        return $this->id;
+    }
+
+    /**
+     * Get the discussion id.
+     *
+     * @return int
+     */
+    public function get_discussion_id() : int {
+        return $this->discussionid;
+    }
+
+    /**
+     * Get the id of the parent post. Returns zero if this post is not a reply.
+     *
+     * @return int
+     */
+    public function get_parent_id() : int {
+        return $this->parentid;
+    }
+
+    /**
+     * Does this post have a parent? I.e. is it a reply?
+     *
+     * @return bool
+     */
+    public function has_parent() : bool {
+        return $this->get_parent_id() > 0;
+    }
+
+    /**
+     * Get the id of the user that authored the post.
+     *
+     * @return int
+     */
+    public function get_author_id() : int {
+        return $this->authorid;
+    }
+
+    /**
+     * Get the timestamp for when this post was created.
+     *
+     * @return int
+     */
+    public function get_time_created() : int {
+        return $this->timecreated;
+    }
+
+    /**
+     * Get the timestamp for when this post was last modified.
+     *
+     * @return int
+     */
+    public function get_time_modified() : int {
+        return $this->timemodified;
+    }
+
+    /**
+     * Has this post been mailed?
+     *
+     * @return bool
+     */
+    public function has_been_mailed() : bool {
+        return $this->mailed;
+    }
+
+    /**
+     * Get the post subject.
+     *
+     * @return string
+     */
+    public function get_subject() : string {
+        return $this->subject;
+    }
+
+    /**
+     * Get the post message.
+     *
+     * @return string
+     */
+    public function get_message() : string {
+        return $this->message;
+    }
+
+    /**
+     * Get the post message format.
+     *
+     * @return int
+     */
+    public function get_message_format() : int {
+        return $this->messageformat;
+    }
+
+    /**
+     * Is this a trusted message? I.e. was it authored by a trusted user?
+     *
+     * @return bool
+     */
+    public function is_message_trusted() : bool {
+        return $this->messagetrust;
+    }
+
+    /**
+     * Does this post have attachments?
+     *
+     * @return bool
+     */
+    public function has_attachments() : bool {
+        return $this->hasattachments;
+    }
+
+    /**
+     * Get the total score.
+     *
+     * @return int
+     */
+    public function get_total_score() : int {
+        return $this->totalscore;
+    }
+
+    /**
+     * Should this post be mailed now?
+     *
+     * @return bool
+     */
+    public function should_mail_now() : bool {
+        return $this->mailnow;
+    }
+
+    /**
+     * Is this post deleted?
+     *
+     * @return bool
+     */
+    public function is_deleted() : bool {
+        return $this->deleted;
+    }
+
+    /**
+     * Get the post's age in seconds.
+     *
+     * @return int
+     */
+    public function get_age() : int {
+        return time() - $this->get_time_created();
+    }
+
+    /**
+     * Check if the given user authored this post.
+     *
+     * @param stdClass $user The user to check.
+     * @return bool
+     */
+    public function is_owned_by_user(stdClass $user) : bool {
+        return $this->get_author_id() == $user->id;
+    }
+}
diff --git a/mod/forum/classes/local/entities/post_read_receipt_collection.php b/mod/forum/classes/local/entities/post_read_receipt_collection.php
new file mode 100644 (file)
index 0000000..d51b66f
--- /dev/null
@@ -0,0 +1,86 @@
+<?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/>.
+
+/**
+ * Post read receipt collection class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\post as post_entity;
+use stdClass;
+
+/**
+ * Post read receipt collection class.
+ *
+ * Contains the list of read receipts for posts.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class post_read_receipt_collection {
+    /** @var stdClass[] $receiptsbypostid Receipt records indexed by post id */
+    private $receiptsbypostid = [];
+
+    /**
+     * Constructor.
+     *
+     * @param array $records The list of post read receipt records.
+     */
+    public function __construct(array $records) {
+        foreach ($records as $record) {
+            $postid = $record->postid;
+
+            if (isset($this->receiptsbypostid[$postid])) {
+                $this->receiptsbypostid[$postid][] = $record;
+            } else {
+                $this->receiptsbypostid[$postid] = [$record];
+            }
+        }
+    }
+
+    /**
+     * Check whether a user has read a post.
+     *
+     * @param stdClass $user The user to check
+     * @param post_entity $post The post to check
+     * @return bool
+     */
+    public function has_user_read_post(stdClass $user, post_entity $post) : bool {
+        global $CFG;
+        $isoldpost = ($post->get_time_modified() < (time() - ($CFG->forum_oldpostdays * 24 * 3600)));
+
+        if ($isoldpost) {
+            return true;
+        }
+
+        $receipts = isset($this->receiptsbypostid[$post->get_id()]) ? $this->receiptsbypostid[$post->get_id()] : [];
+
+        foreach ($receipts as $receipt) {
+            if ($receipt->userid == $user->id) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/mod/forum/classes/local/entities/sorter.php b/mod/forum/classes/local/entities/sorter.php
new file mode 100644 (file)
index 0000000..688bfb7
--- /dev/null
@@ -0,0 +1,152 @@
+<?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/>.
+
+/**
+ * Class to sort items.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\entities;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class to sort lists of items.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sorter {
+    /** @var callable $getid Function used to get the id from an item */
+    private $getid;
+    /** @var callable $getparentid Function used to get the parent id from an item */
+    private $getparentid;
+
+    /**
+     * Constructor.
+     *
+     * Allows the calling code to provide 2 functions to get the id and parent id from
+     * the list of items it is intended to process.
+     *
+     * This allows this class to be composed in numerous different ways to support various
+     * types of items while keeping the underlying sorting algorithm consistent.
+     *
+     * @param callable $getid Function used to get the id from an item
+     * @param callable $getparentid Function used to get the parent id from an item
+     */
+    public function __construct(callable $getid, callable $getparentid) {
+        $this->getid = $getid;
+        $this->getparentid = $getparentid;
+    }
+
+    /**
+     * Sort a list of items into a parent/child data structure. The resulting data structure
+     * is a recursive array of arrays where the first element is the parent and the second is
+     * an array of it's children.
+     *
+     * For example
+     * If we have an array of items A, B, C, and D where D is a child of C, B and C are children
+     * of A.
+     *
+     * This function would sort them into the following:
+     * [
+     *      [
+     *          A,
+     *          [
+     *              [
+     *                  B,
+     *                  []
+     *              ],
+     *              [
+     *                  C,
+     *                  [
+     *                      [
+     *                          D,
+     *                          []
+     *                      ]
+     *                  ]
+     *              ]
+     *          ]
+     *      ]
+     * ]
+     *
+     * @param array $items The list of items to sort.
+     * @return array
+     */
+    public function sort_into_children(array $items) : array {
+        $ids = array_reduce($items, function($carry, $item) {
+            $carry[($this->getid)($item)] = true;
+            return $carry;
+        }, []);
+
+        // Split out the items into "parents" and "replies" (children). These are unsorted
+        // at this point.
+        [$parents, $replies] = array_reduce($items, function($carry, $item) use ($ids) {
+            $parentid = ($this->getparentid)($item);
+
+            if (!empty($ids[$parentid])) {
+                // This is a child to another item in the list so add it to the children list.
+                $carry[1][] = $item;
+            } else {
+                // This isn't a child to anything in our list so it's a parent.
+                $carry[0][] = $item;
+            }
+
+            return $carry;
+        }, [[], []]);
+
+        if (empty($replies)) {
+            return array_map(function($parent) {
+                return [$parent, []];
+            }, $parents);
+        }
+
+        // Recurse to sort the replies into the correct nesting.
+        $sortedreplies = $this->sort_into_children($replies);
+
+        // Sort the parents and sorted replies into their matching pairs.
+        return array_map(function($parent) use ($sortedreplies) {
+            $parentid = ($this->getid)($parent);
+            return [
+                $parent,
+                array_values(array_filter($sortedreplies, function($replydata) use ($parentid) {
+                    return ($this->getparentid)($replydata[0]) == $parentid;
+                }))
+            ];
+        }, $parents);
+    }
+
+    /**
+     * Take the data structure returned from "sort_into_children" and flatten it back
+     * into an array. It does a depth first flatten which maintains the reply ordering.
+     *
+     * @param array $items Items in the data structure returned by "sort_into_children"
+     * @return array A flat array.
+     */
+    public function flatten_children(array $items) : array {
+        $result = [];
+
+        foreach ($items as [$item, $children]) {
+            $result[] = $item;
+            $result = array_merge($result, $this->flatten_children($children));
+        }
+
+        return $result;
+    }
+}
diff --git a/mod/forum/classes/local/exporters/author.php b/mod/forum/classes/local/exporters/author.php
new file mode 100644 (file)
index 0000000..b4793c9
--- /dev/null
@@ -0,0 +1,174 @@
+<?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/>.
+
+/**
+ * Author exporter.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\author as author_entity;
+use core\external\exporter;
+use renderer_base;
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Author exporter.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class author extends exporter {
+    /** @var author_entity $author Author entity */
+    private $author;
+    /** @var array $authorgroups List of groups that the author belongs to */
+    private $authorgroups;
+    /** @var bool $canview Should the author be anonymised? */
+    private $canview;
+
+    /**
+     * Constructor.
+     *
+     * @param author_entity $author The author entity to export
+     * @param stdClass[] $authorgroups The list of groups that the author belongs to
+     * @param bool $canview Can the requesting user view this author or should it be anonymised?
+     * @param array $related The related data for the export.
+     */
+    public function __construct(author_entity $author, array $authorgroups = [], bool $canview = true, array $related = []) {
+        $this->author = $author;
+        $this->authorgroups = $authorgroups;
+        $this->canview = $canview;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'id' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'fullname' => [
+                'type' => PARAM_TEXT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'groups' => [
+                'multiple' => true,
+                'optional' => true,
+                'type' => [
+                    'id' => ['type' => PARAM_INT],
+                    'urls' => [
+                        'type' => [
+                            'image' => [
+                                'type' => PARAM_URL,
+                                'optional' => true,
+                                'default' => null,
+                                'null' => NULL_ALLOWED
+                            ]
+                        ]
+                    ]
+                ]
+            ],
+            'urls' => [
+                'type' => [
+                    'profile' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'profileimage' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $author = $this->author;
+        $urlfactory = $this->related['urlfactory'];
+
+        if ($this->canview) {
+            $groups = array_map(function($group) {
+                $imageurl = get_group_picture_url($group, $group->courseid);
+                return [
+                    'id' => $group->id,
+                    'urls' => [
+                        'image' => $imageurl ? $imageurl->out(false) : null
+                    ]
+                ];
+            }, $this->authorgroups);
+
+            return [
+                'id' => $author->get_id(),
+                'fullname' => $author->get_full_name(),
+                'groups' => $groups,
+                'urls' => [
+                    'profile' => ($urlfactory->get_author_profile_url($author))->out(false),
+                    'profileimage' => ($urlfactory->get_author_profile_image_url($author))->out(false)
+                ]
+            ];
+        } else {
+            // The author should be anonymised.
+            return [
+                'id' => null,
+                'fullname' => get_string('forumauthorhidden', 'mod_forum'),
+                'groups' => [],
+                'urls' => [
+                    'profile' => null,
+                    'profileimage' => null
+                ]
+            ];
+        }
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'context' => 'context'
+        ];
+    }
+}
diff --git a/mod/forum/classes/local/exporters/discussion.php b/mod/forum/classes/local/exporters/discussion.php
new file mode 100644 (file)
index 0000000..d23f32d
--- /dev/null
@@ -0,0 +1,253 @@
+<?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/>.
+
+/**
+ * Discussion exporter class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\exporters\post as post_exporter;
+use mod_forum\local\factories\exporter as exporter_factory;
+use core\external\exporter;
+use renderer_base;
+
+/**
+ * Discussion exporter class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion extends exporter {
+    /** @var discussion_entity $discussion Discussion to export */
+    private $discussion;
+
+    /**
+     * Constructor.
+     *
+     * @param discussion_entity $discussion Discussion to export
+     * @param array $related The related export data
+     */
+    public function __construct(discussion_entity $discussion, array $related = []) {
+        $this->discussion = $discussion;
+
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'id' => ['type' => PARAM_INT],
+            'forumid' => ['type' => PARAM_INT],
+            'pinned' => ['type' => PARAM_BOOL],
+            'name' => ['type' => PARAM_TEXT],
+            'group' => [
+                'optional' => true,
+                'type' => [
+                    'name' => ['type' => PARAM_TEXT],
+                    'urls' => [
+                        'type' => [
+                            'picture' => [
+                                'optional' => true,
+                                'type' => PARAM_URL,
+                            ],
+                            'userlist' => [
+                                'optional' => true,
+                                'type' => PARAM_URL,
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            'times' => [
+                'type' => [
+                    'modified' => ['type' => PARAM_INT],
+                    'start' => ['type' => PARAM_INT],
+                    'end' => ['type' => PARAM_INT],
+                ],
+            ],
+            'userstate' => [
+                'type' => [
+                    'subscribed' => ['type' => PARAM_BOOL],
+                ],
+            ],
+            'capabilities' => [
+                'type' => [
+                    'subscribe' => ['type' => PARAM_BOOL],
+                    'move' => ['type' => PARAM_BOOL],
+                    'pin' => ['type' => PARAM_BOOL],
+                    'post' => ['type' => PARAM_BOOL]
+                ]
+            ],
+            'urls' => [
+                'type' => [
+                    'view' => ['type' => PARAM_URL],
+                    'viewlatest' => [
+                        'optional' => true,
+                        'type' => PARAM_URL
+                    ],
+                    'viewfirstunread' => [
+                        'optional' => true,
+                        'type' => PARAM_URL,
+                    ],
+                    'markasread' => ['type' => PARAM_URL],
+                    'subscribe' => ['type' => PARAM_URL]
+                ],
+            ],
+            'timed' => [
+                'type' => [
+                    'istimed' => [
+                        'type' => PARAM_BOOL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'visible' => [
+                        'type' => PARAM_BOOL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ]
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+
+        $capabilitymanager = $this->related['capabilitymanager'];
+        $urlfactory = $this->related['urlfactory'];
+
+        $forum = $this->related['forum'];
+        $forumrecord = $this->get_forum_record();
+        $user = $this->related['user'];
+        $discussion = $this->discussion;
+
+        $groupdata = null;
+        if ($discussion->has_group() && $group = $this->related['groupsbyid'][$discussion->get_group_id()]) {
+            $groupdata = [
+                'name' => $group->name,
+                'urls' => [],
+            ];
+            $canviewparticipants = $capabilitymanager->can_view_participants($user, $discussion);
+            if (!$group->hidepicture) {
+                $url = get_group_picture_url($group, $forum->get_course_id());
+                if (!empty($url)) {
+                    $groupdata['urls']['picture'] = $url;
+                }
+            }
+            if ($canviewparticipants) {
+                $groupdata['urls']['userlist'] = (new \moodle_url('/user/index.php', [
+                    'id' => $forum->get_course_id(),
+                    'group' => $group->id,
+                ]));
+            }
+        }
+
+        $viewfirstunreadurl = $urlfactory->get_discussion_view_first_unread_post_url_from_discussion($discussion);
+        $data = [
+            'id' => $discussion->get_id(),
+            'forumid' => $forum->get_id(),
+            'pinned' => $discussion->is_pinned(),
+            'name' => format_string($discussion->get_name(), true, [
+                'context' => $this->related['context']
+            ]),
+            'times' => [
+                'modified' => $discussion->get_time_modified(),
+                'start' => $discussion->get_time_start(),
+                'end' => $discussion->get_time_end(),
+            ],
+            'userstate' => [
+                'subscribed' => \mod_forum\subscriptions::is_subscribed($user->id, $forumrecord, $discussion->get_id()),
+            ],
+            'capabilities' => [
+                'subscribe' => $capabilitymanager->can_subscribe_to_discussion($user, $discussion),
+                'move' => $capabilitymanager->can_move_discussion($user, $discussion),
+                'pin' => $capabilitymanager->can_pin_discussion($user, $discussion),
+                'post' => $capabilitymanager->can_post_in_discussion($user, $discussion)
+            ],
+            'urls' => [
+                'view' => $urlfactory->get_discussion_view_url_from_discussion($discussion)->out(false),
+                'viewfirstunread' => $viewfirstunreadurl->out(false),
+                'markasread' => $urlfactory->get_mark_discussion_as_read_url_from_discussion($forum, $discussion)->out(false),
+                'subscribe' => $urlfactory->get_discussion_subscribe_url($discussion)->out(false)
+            ]
+        ];
+
+        if (!empty($this->related['latestpostid'])) {
+            $data['urls']['viewlatest'] = $urlfactory->get_discussion_view_latest_post_url_from_discussion(
+                    $discussion,
+                    $this->related['latestpostid']
+                )->out(false);
+        }
+
+        if ($groupdata) {
+            $data['group'] = $groupdata;
+        }
+
+        $canviewhiddentimedposts = $capabilitymanager->can_view_hidden_posts($user);
+        $canalwaysseetimedpost = $user->id == $discussion->get_user_id() || $canviewhiddentimedposts;
+        $data['timed']['istimed'] = $canalwaysseetimedpost ? $discussion->is_timed_discussion() : null;
+        $data['timed']['visible'] = $canalwaysseetimedpost ? $discussion->is_timed_discussion_visible() : null;
+
+        return $data;
+    }
+
+    /**
+     * Get the legacy forum record from the forum entity.
+     *
+     * @return stdClass
+     */
+    private function get_forum_record() {
+        $forumdbdatamapper = $this->related['legacydatamapperfactory']->get_forum_data_mapper();
+        return $forumdbdatamapper->to_legacy_object($this->related['forum']);
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'legacydatamapperfactory' => 'mod_forum\local\factories\legacy_data_mapper',
+            'context' => 'context',
+            'forum' => 'mod_forum\local\entities\forum',
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'user' => 'stdClass',
+            'groupsbyid' => 'stdClass[]',
+            'latestpostid' => 'int?'
+        ];
+    }
+}
diff --git a/mod/forum/classes/local/exporters/discussion_summaries.php b/mod/forum/classes/local/exporters/discussion_summaries.php
new file mode 100644 (file)
index 0000000..ded471d
--- /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/>.
+
+/**
+ * Discussion summaries exporter.
+ *
+ * @package     mod_forum
+ * @copyright   2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\exporters\post as post_exporter;
+use core\external\exporter;
+use renderer_base;
+
+/**
+ * Discussion summaries exporter.
+ *
+ * @copyright   2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion_summaries extends exporter {
+    /** @var discussion_summary_entity[] The list of discussion summaries to export */
+    private $discussions;
+
+    /** @var stdClass[] The group information for each author */
+    private $groupsbyid;
+
+    /** @var stdClass[] The group information for each author */
+    private $groupsbyauthorid;
+
+    /** @var int[] Discussion reply counts indexed by dicussion id */
+    private $discussionreplycount;
+
+    /** @var int[] Discussion unread counts indexed by dicussion id */
+    private $discussionunreadcount;
+
+    /** @var array The latest post in each discussion */
+    private $latestpostids;
+
+    /**
+     * Constructor.
+     *
+     * @param discussion_summary_entity[] $discussion The list of discussion summaries to export
+     * @param stdClass[] $groupsbyid The group information for each author
+     * @param stdClass[] $groupsbyauthorid The group information for each author
+     * @param int[] $discussionreplycount Discussion reply counts indexed by dicussion id
+     * @param int[] $discussionunreadcount Discussion unread counts indexed by dicussion id
+     * @param int[] $latestpostids List of latest post ids indexed by discussion id
+     * @param array $related The related
+     */
+    public function __construct(
+        array $discussions,
+        array $groupsbyid,
+        array $groupsbyauthorid,
+        array $discussionreplycount,
+        array $discussionunreadcount,
+        array $latestpostids,
+        array $related = []
+    ) {
+        $this->discussions = $discussions;
+        $this->groupsbyid = $groupsbyid;
+        $this->groupsbyauthorid = $groupsbyauthorid;
+        $this->discussionreplycount = $discussionreplycount;
+        $this->discussionunreadcount = $discussionunreadcount;
+        $this->latestpostids = $latestpostids;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'summaries' => [
+                'type' => discussion_summary::read_properties_definition(),
+                'multiple' => true
+            ],
+            'state' => [
+                'type' => [
+                    'hasdiscussions' => ['type' => PARAM_BOOL],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $exporteddiscussions = [];
+        $related = $this->related;
+
+        foreach ($this->discussions as $discussion) {
+            $discussionid = $discussion->get_discussion()->get_id();
+            $replycount = isset($this->discussionreplycount[$discussionid]) ? $this->discussionreplycount[$discussionid] : 0;
+            $unreadcount = isset($this->discussionunreadcount[$discussionid]) ? $this->discussionunreadcount[$discussionid] : 0;
+            $latestpostid = isset($this->latestpostids[$discussionid]) ? $this->latestpostids[$discussionid] : 0;
+            $exporter = new discussion_summary(
+                    $discussion,
+                    $this->groupsbyid,
+                    $this->groupsbyauthorid,
+                    $replycount,
+                    $unreadcount,
+                    $latestpostid,
+                    $related
+                );
+            $exporteddiscussions[] = $exporter->export($output);
+        }
+
+        return [
+            'summaries' => $exporteddiscussions,
+            'state' => [
+                'hasdiscussions' => !empty($exporteddiscussions),
+            ],
+        ];
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'legacydatamapperfactory' => 'mod_forum\local\factories\legacy_data_mapper',
+            'context' => 'context',
+            'forum' => 'mod_forum\local\entities\forum',
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'user' => 'stdClass',
+        ];
+    }
+}
diff --git a/mod/forum/classes/local/exporters/discussion_summary.php b/mod/forum/classes/local/exporters/discussion_summary.php
new file mode 100644 (file)
index 0000000..1a7ac8e
--- /dev/null
@@ -0,0 +1,180 @@
+<?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/>.
+
+/**
+ * Discussion summary exporter class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion_summary as discussion_summary_entity;
+use core\external\exporter;
+use renderer_base;
+
+/**
+ * Discussion summary exporter class.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class discussion_summary extends exporter {
+    /** @var discussion_summary_entity The discussion summary information */
+    private $summary;
+
+    /** @var stdClass[] The group information for each author */
+    private $groupsbyid;
+
+    /** @var stdClass[] The group information for each author */
+    private $groupsbyauthorid;
+
+    /** @var int The number of replies to the discussion */
+    private $replycount;
+
+    /** @var int number of unread posts if the user is tracking these */
+    private $unreadcount;
+
+    /** @var int The latest post id in the discussion */
+    private $latestpostid;
+
+    /**
+     * Constructor.
+     *
+     * @param discussion_summary_entity $summary The discussion summary information
+     * @param stdClass[] $groupsbyid The group information for each author
+     * @param stdClass[] $groupsbyauthorid The group information for each author
+     * @param int $replycount The number of replies to the discussion
+     * @param int $unreadcount number of unread posts if the user is tracking these
+     * @param int $latestpostid The latest post id in the discussion
+     * @param array $related The related objects
+     */
+    public function __construct(
+        discussion_summary_entity $summary,
+        array $groupsbyid,
+        array $groupsbyauthorid,
+        int $replycount,
+        int $unreadcount,
+        int $latestpostid,
+        array $related = []
+    ) {
+        $this->summary = $summary;
+        $this->groupsbyid = $groupsbyid;
+        $this->groupsbyauthorid = $groupsbyauthorid;
+        $this->replycount = $replycount;
+        $this->unreadcount = $unreadcount;
+        $this->latestpostid = $latestpostid;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'id' => ['type' => PARAM_INT],
+            'discussion' => [
+                'type' => discussion::read_properties_definition(),
+            ],
+            'replies' => ['type' => PARAM_INT],
+            'unread' => ['type' => PARAM_INT],
+            'firstpostauthor' => [
+                'type' => author::read_properties_definition(),
+            ],
+            'latestpostauthor' => [
+                'type' => author::read_properties_definition(),
+            ],
+            'latestpostid' => ['type' => PARAM_INT],
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $capabilitymanager = $this->related['capabilitymanager'];
+        $forum = $this->related['forum'];
+        $user = $this->related['user'];
+        $discussion = $this->summary->get_discussion();
+
+        $related = (array) (object) $this->related;
+        $related['latestpostid'] = $this->latestpostid;
+        $related['groupsbyid'] = $this->groupsbyid;
+        $discussionexporter = new discussion($discussion, $related);
+
+        $related = [
+            'urlfactory' => $this->related['urlfactory'],
+            'context' => $this->related['forum']->get_context(),
+        ];
+
+        $firstpostauthor = new author(
+            $this->summary->get_first_post_author(),
+            $this->groupsbyauthorid[$this->summary->get_first_post_author()->get_id()],
+            $capabilitymanager->can_view_post(
+                $user,
+                $discussion,
+                $this->summary->get_first_post()
+            ),
+            $related
+        );
+
+        $latestpostauthor = new author(
+            $this->summary->get_latest_post_author(),
+            [],
+            $capabilitymanager->can_view_post(
+                $user,
+                $discussion,
+                $this->summary->get_first_post()
+            ),
+            $related
+        );
+
+        return [
+            'id' => $discussion->get_id(),
+            'discussion' => $discussionexporter->export($output),
+            'replies' => $this->replycount,
+            'unread' => $this->unreadcount,
+            'firstpostauthor' => $firstpostauthor->export($output),
+            'latestpostauthor' => $latestpostauthor->export($output),
+            'latestpostid' => $this->latestpostid,
+        ];
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'legacydatamapperfactory' => 'mod_forum\local\factories\legacy_data_mapper',
+            'context' => 'context',
+            'forum' => 'mod_forum\local\entities\forum',
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'user' => 'stdClass',
+        ];
+    }
+}
diff --git a/mod/forum/classes/local/exporters/forum.php b/mod/forum/classes/local/exporters/forum.php
new file mode 100644 (file)
index 0000000..160b37d
--- /dev/null
@@ -0,0 +1,148 @@
+<?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/>.
+
+/**
+ * Forum Exporter.
+ *
+ * @package     mod_forum
+ * @copyright   2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\exporters\post as post_exporter;
+use core\external\exporter;
+use renderer_base;
+use stdClass;
+
+/**
+ * Forum class.
+ *
+ * @copyright   2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class forum extends exporter {
+    /** @var forum_entity The entity relating to the forum being displayed */
+    private $forum;
+
+    /**
+     * Constructor for the forum exporter.
+     *
+     * @param   forum_entity    $forum The forum being displayed
+     * @param   array           $related The related objects
+     */
+    public function __construct(forum_entity $forum, $related = []) {
+        $this->forum = $forum;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'id' => ['type' => PARAM_INT],
+            'state' => [
+                'type' => [
+                    'groupmode' => ['type' => PARAM_INT],
+                ],
+            ],
+            'userstate' => [
+                'type' => [
+                    'tracked' => ['type' => PARAM_INT],
+                ],
+            ],
+            'capabilities' => [
+                'type' => [
+                    'viewdiscussions' => ['type' => PARAM_BOOL],
+                    'create' => ['type' => PARAM_BOOL],
+                    'subscribe' => ['type' => PARAM_BOOL],
+                ]
+            ],
+            'urls' => [
+                'type' => [
+                    'create' => ['type' => PARAM_URL],
+                    'markasread' => ['type' => PARAM_URL],
+                    'view' => ['type' => PARAM_URL],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $capabilitymanager = $this->related['capabilitymanager'];
+        $urlfactory = $this->related['urlfactory'];
+        $user = $this->related['user'];
+        $currentgroup = $this->related['currentgroup'];
+
+        return [
+            'id' => $this->forum->get_id(),
+            'state' => [
+                'groupmode' => $this->forum->get_effective_group_mode(),
+            ],
+            'userstate' => [
+                'tracked' => forum_tp_is_tracked($this->get_forum_record(), $this->related['user']),
+            ],
+            'capabilities' => [
+                'viewdiscussions' => $capabilitymanager->can_view_discussions($user),
+                'create' => $capabilitymanager->can_create_discussions($user, $currentgroup),
+                'subscribe' => $capabilitymanager->can_subscribe_to_forum($user),
+            ],
+            'urls' => [
+                'create' => $urlfactory->get_discussion_create_url($this->forum)->out(false),
+                'markasread' => $urlfactory->get_mark_all_discussions_as_read_url($this->forum)->out(false),
+                'view' => $urlfactory->get_forum_view_url_from_forum($this->forum)->out(false),
+            ],
+        ];
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'legacydatamapperfactory' => 'mod_forum\local\factories\legacy_data_mapper',
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'user' => 'stdClass',
+            'currentgroup' => 'int?',
+        ];
+    }
+
+    /**
+     * Get the legacy forum record for this forum.
+     *
+     * @return  stdClass
+     */
+    private function get_forum_record() : stdClass {
+        $forumdbdatamapper = $this->related['legacydatamapperfactory']->get_forum_data_mapper();
+        return $forumdbdatamapper->to_legacy_object($this->forum);
+    }
+}
diff --git a/mod/forum/classes/local/exporters/post.php b/mod/forum/classes/local/exporters/post.php
new file mode 100644 (file)
index 0000000..17c31b2
--- /dev/null
@@ -0,0 +1,531 @@
+<?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/>.
+
+/**
+ * Post exporter class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\exporters\author as author_exporter;
+use mod_forum\local\factories\exporter as exporter_factory;
+use core\external\exporter;
+use core_files\external\stored_file_exporter;
+use context;
+use core_tag_tag;
+use renderer_base;
+use stdClass;
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Post exporter class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class post extends exporter {
+    /** @var post_entity $post The post to export */
+    private $post;
+
+    /**
+     * Constructor.
+     *
+     * @param post_entity $post The post to export
+     * @param array $related List of related data
+     */
+    public function __construct(post_entity $post, array $related = []) {
+        $this->post = $post;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        $attachmentdefinition = stored_file_exporter::read_properties_definition();
+        $attachmentdefinition['urls'] = [
+            'type' => [
+                'export' => [
+                    'type' => PARAM_URL,
+                    'optional' => true,
+                    'default' => null,
+                    'null' => NULL_ALLOWED
+                ]
+            ]
+        ];
+        $attachmentdefinition['html'] = [
+            'type' => [
+                'plagiarism' => [
+                    'type' => PARAM_RAW,
+                    'optional' => true,
+                    'default' => null,
+                    'null' => NULL_ALLOWED
+                ],
+            ]
+        ];
+
+        return [
+            'id' => ['type' => PARAM_INT],
+            'subject' => ['type' => PARAM_TEXT],
+            'message' => ['type' => PARAM_RAW],
+            'messageformat' => ['type' => PARAM_INT],
+            'author' => ['type' => author_exporter::read_properties_definition()],
+            'discussionid' => ['type' => PARAM_INT],
+            'hasparent' => ['type' => PARAM_BOOL],
+            'parentid' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'timecreated' => ['type' => PARAM_INT],
+            'unread' => [
+                'type' => PARAM_BOOL,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'isdeleted' => ['type' => PARAM_BOOL],
+            'haswordcount' => ['type' => PARAM_BOOL],
+            'wordcount' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'capabilities' => [
+                'type' => [
+                    'view' => ['type' => PARAM_BOOL],
+                    'edit' => ['type' => PARAM_BOOL],
+                    'delete' => ['type' => PARAM_BOOL],
+                    'split' => ['type' => PARAM_BOOL],
+                    'reply' => ['type' => PARAM_BOOL],
+                    'export' => ['type' => PARAM_BOOL],
+                    'controlreadstatus' => ['type' => PARAM_BOOL]
+                ]
+            ],
+            'urls' => [
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED,
+                'type' => [
+                    'view' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'viewisolated' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'viewparent' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'edit' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'delete' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'split' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'reply' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'export' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'markasread' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'markasunread' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ],
+                    'discuss' => [
+                        'type' => PARAM_URL,
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED
+                    ]
+                ]
+            ],
+            'attachments' => [
+                'multiple' => true,
+                'type' => $attachmentdefinition
+            ],
+            'tags' => [
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED,
+                'multiple' => true,
+                'type' => [
+                    'id' => ['type' => PARAM_INT],
+                    'tagid' => ['type' => PARAM_INT],
+                    'isstandard' => ['type' => PARAM_BOOL],
+                    'displayname' => ['type' => PARAM_TEXT],
+                    'flag' => ['type' => PARAM_BOOL],
+                    'urls' => [
+                        'type' => [
+                            'view' => ['type' => PARAM_URL]
+                        ]
+                    ]
+                ]
+            ],
+            'html' => [
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED,
+                'type' => [
+                    'rating' => [
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED,
+                        'type' => PARAM_RAW
+                    ],
+                    'taglist' => [
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED,
+                        'type' => PARAM_RAW
+                    ],
+                    'authorsubheading' => [
+                        'optional' => true,
+                        'default' => null,
+                        'null' => NULL_ALLOWED,
+                        'type' => PARAM_RAW
+                    ],
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $post = $this->post;
+        $authorgroups = $this->related['authorgroups'];
+        $forum = $this->related['forum'];
+        $discussion = $this->related['discussion'];
+        $author = $this->related['author'];
+        $user = $this->related['user'];
+        $readreceiptcollection = $this->related['readreceiptcollection'];
+        $rating = $this->related['rating'];
+        $tags = $this->related['tags'];
+        $attachments = $this->related['attachments'];
+        $includehtml = $this->related['includehtml'];
+        $isdeleted = $post->is_deleted();
+        $hasrating = $rating != null;
+        $hastags = !empty($tags);
+        $discussionid = $post->get_discussion_id();
+        $parentid = $post->get_parent_id();
+
+        $capabilitymanager = $this->related['capabilitymanager'];
+        $canview = $capabilitymanager->can_view_post($user, $discussion, $post);
+        $canedit = $capabilitymanager->can_edit_post($user, $discussion, $post);
+        $candelete = $capabilitymanager->can_delete_post($user, $discussion, $post);
+        $cansplit = $capabilitymanager->can_split_post($user, $discussion, $post);
+        $canreply = $capabilitymanager->can_reply_to_post($user, $discussion, $post);
+        $canexport = $capabilitymanager->can_export_post($user, $post);
+        $cancontrolreadstatus = $capabilitymanager->can_manually_control_post_read_status($user);
+
+        $urlfactory = $this->related['urlfactory'];
+        $viewurl = $canview ? $urlfactory->get_view_post_url_from_post($post) : null;
+        $viewisolatedurl = $canview ? $urlfactory->get_view_isolated_post_url_from_post($post) : null;
+        $viewparenturl = $post->has_parent() ? $urlfactory->get_view_post_url_from_post_id($discussionid, $parentid) : null;
+        $editurl = $canedit ? $urlfactory->get_edit_post_url_from_post($forum, $post) : null;
+        $deleteurl = $candelete ? $urlfactory->get_delete_post_url_from_post($post) : null;
+        $spliturl = $cansplit ? $urlfactory->get_split_discussion_at_post_url_from_post($post) : null;
+        $replyurl = $canreply ? $urlfactory->get_reply_to_post_url_from_post($post) : null;
+        $exporturl = $canexport ? $urlfactory->get_export_post_url_from_post($post) : null;
+        $markasreadurl = $cancontrolreadstatus ? $urlfactory->get_mark_post_as_read_url_from_post($post) : null;
+        $markasunreadurl = $cancontrolreadstatus ? $urlfactory->get_mark_post_as_unread_url_from_post($post) : null;
+        $discussurl = $canview ? $urlfactory->get_discussion_view_url_from_post($post) : null;
+
+        $authorexporter = new author_exporter($author, $authorgroups, ($canview && !$isdeleted), $this->related);
+        $exportedauthor = $authorexporter->export($output);
+        // Only bother loading the content if the user can see it.
+        $loadcontent = $canview && !$isdeleted;
+        $exportattachments = $loadcontent && !empty($attachments);
+
+        if ($loadcontent) {
+            $subject = $post->get_subject();
+            $timecreated = $post->get_time_created();
+            $message = $this->get_message($post);
+        } else {
+            $subject = $isdeleted ? get_string('forumsubjectdeleted', 'forum') : get_string('forumsubjecthidden', 'forum');
+            $message = $isdeleted ? get_string('forumbodydeleted', 'forum') : get_string('forumbodyhidden', 'forum');
+            $timecreated = null;
+
+            if ($isdeleted) {
+                $exportedauthor->fullname = null;
+            }
+        }
+
+        return [
+            'id' => $post->get_id(),
+            'subject' => $subject,
+            'message' => $message,
+            'messageformat' => $post->get_message_format(),
+            'author' => $exportedauthor,
+            'discussionid' => $post->get_discussion_id(),
+            'hasparent' => $post->has_parent(),
+            'parentid' => $post->has_parent() ? $post->get_parent_id() : null,
+            'timecreated' => $timecreated,
+            'unread' => ($loadcontent && $readreceiptcollection) ? !$readreceiptcollection->has_user_read_post($user, $post) : null,
+            'isdeleted' => $isdeleted,
+            'haswordcount' => $forum->should_display_word_count(),
+            'wordcount' => $forum->should_display_word_count() ? count_words($message) : null,
+            'capabilities' => [
+                'view' => $canview,
+                'edit' => $canedit,
+                'delete' => $candelete,
+                'split' => $cansplit,
+                'reply' => $canreply,
+                'export' => $canexport,
+                'controlreadstatus' => $cancontrolreadstatus
+            ],
+            'urls' => [
+                'view' => $viewurl ? $viewurl->out(false) : null,
+                'viewisolated' => $viewisolatedurl ? $viewisolatedurl->out(false) : null,
+                'viewparent' => $viewparenturl ? $viewparenturl->out(false) : null,
+                'edit' => $editurl ? $editurl->out(false) : null,
+                'delete' => $deleteurl ? $deleteurl->out(false) : null,
+                'split' => $spliturl ? $spliturl->out(false) : null,
+                'reply' => $replyurl ? $replyurl->out(false) : null,
+                'export' => $exporturl && $exporturl ? $exporturl->out(false) : null,
+                'markasread' => $markasreadurl ? $markasreadurl->out(false) : null,
+                'markasunread' => $markasunreadurl ? $markasunreadurl->out(false) : null,
+                'discuss' => $discussurl ? $discussurl->out(false) : null,
+            ],
+            'attachments' => ($exportattachments) ? $this->export_attachments($attachments, $post, $output, $canexport) : [],
+            'tags' => ($loadcontent && $hastags) ? $this->export_tags($tags) : [],
+            'html' => $includehtml ? [
+                'rating' => ($loadcontent && $hasrating) ? $output->render($rating) : null,
+                'taglist' => ($loadcontent && $hastags) ? $output->tag_list($tags) : null,
+                'authorsubheading' => ($loadcontent) ? $this->get_author_subheading_html($exportedauthor, $timecreated) : null
+            ] : null
+        ];
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'readreceiptcollection' => 'mod_forum\local\entities\post_read_receipt_collection?',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'forum' => 'mod_forum\local\entities\forum',
+            'discussion' => 'mod_forum\local\entities\discussion',
+            'author' => 'mod_forum\local\entities\author',
+            'user' => 'stdClass',
+            'context' => 'context',
+            'authorgroups' => 'stdClass[]',
+            'attachments' => '\stored_file[]?',
+            'tags' => '\core_tag_tag[]?',
+            'rating' => 'rating?',
+            'includehtml' => 'bool'
+        ];
+    }
+
+    /**
+     * Get the message text from a post.
+     *
+     * @param post_entity $post The post
+     * @return string
+     */
+    private function get_message(post_entity $post) : string {
+        global $CFG;
+
+        $context = $this->related['context'];
+        $message = file_rewrite_pluginfile_urls(
+            $post->get_message(),
+            'pluginfile.php',
+            $context->id,
+            'mod_forum',
+            'post',
+            $post->get_id()
+        );
+
+        if (!empty($CFG->enableplagiarism)) {
+            require_once($CFG->libdir . '/plagiarismlib.php');
+            $forum = $this->related['forum'];
+            $message .= plagiarism_get_links([
+                'userid' => $post->get_author_id(),
+                'content' => $message,
+                'cmid' => $forum->get_course_module_record()->id,
+                'course' => $forum->get_course_id(),
+                'forum' => $forum->get_id()
+            ]);
+        }
+
+        $message = format_text(
+            $message,
+            $post->get_message_format(),
+            (object) [
+                'para' => false,
+                'trusted' => $post->is_message_trusted(),
+                'context' => $context
+            ]
+        );
+
+        return $message;
+    }
+
+    /**
+     * Get the exported attachments for a post.
+     *
+     * @param stored_file[] $attachments The list of attachments for the post
+     * @param post_entity $post The post being exported
+     * @param renderer_base $output Renderer base
+     * @param bool $canexport If the user can export the post (relates to portfolios not exporters like this class)
+     * @return array
+     */
+    private function export_attachments(array $attachments, post_entity $post, renderer_base $output, bool $canexport) : array {
+        global $CFG;
+
+        $urlfactory = $this->related['urlfactory'];
+        $enableplagiarism = $CFG->enableplagiarism;
+        $forum = $this->related['forum'];
+        $context = $this->related['context'];
+
+        if ($enableplagiarism) {
+            require_once($CFG->libdir . '/plagiarismlib.php' );
+        }
+
+        return array_map(function($attachment) use (
+            $output,
+            $enableplagiarism,
+            $canexport,
+            $context,
+            $forum,
+            $post,
+            $urlfactory
+        ) {
+            $exporter = new stored_file_exporter($attachment, ['context' => $context]);
+            $exportedattachment = $exporter->export($output);
+            $exporturl = $canexport ? $urlfactory->get_export_attachment_url_from_post_and_attachment($post, $attachment) : null;
+
+            if ($enableplagiarism) {
+                $plagiarismhtml = plagiarism_get_links([
+                    'userid' => $post->get_author_id(),
+                    'file' => $attachment,
+                    'cmid' => $forum->get_course_module_record()->id,
+                    'course' => $forum->get_course_id(),
+                    'forum' => $forum->get_id()
+                ]);
+            } else {
+                $plagiarismhtml = null;
+            }
+
+            $exportedattachment->urls = [
+                'export' => $exporturl ? $exporturl->out(false) : null
+            ];
+            $exportedattachment->html = [
+                'plagiarism' => $plagiarismhtml
+            ];
+
+            return $exportedattachment;
+        }, $attachments);
+    }
+
+    /**
+     * Export the list of tags.
+     *
+     * @param core_tag_tag[] $tags List of tags to export
+     * @return array
+     */
+    private function export_tags(array $tags) : array {
+        $user = $this->related['user'];
+        $context = $this->related['context'];
+        $capabilitymanager = $this->related['capabilitymanager'];
+        $canmanagetags = $capabilitymanager->can_manage_tags($user);
+
+        return array_values(array_map(function($tag) use ($context, $canmanagetags) {
+            $viewurl = core_tag_tag::make_url($tag->tagcollid, $tag->rawname, 0, $context->id);
+            return [
+                'id' => $tag->taginstanceid,
+                'tagid' => $tag->id,
+                'isstandard' => $tag->isstandard,
+                'displayname' => $tag->get_display_name(),
+                'flag' => $canmanagetags && !empty($tag->flag),
+                'urls' => [
+                    'view' => $viewurl->out(false)
+                ]
+            ];
+        }, $tags));
+    }
+
+    /**
+     * Get the HTML to display as a subheading in a post.
+     *
+     * @param stdClass $exportedauthor The exported author object
+     * @param int $timecreated The post time created timestamp if it's to be displayed
+     * @return string
+     */
+    private function get_author_subheading_html(stdClass $exportedauthor, int $timecreated) : string {
+        $fullname = $exportedauthor->fullname;
+        $profileurl = $exportedauthor->urls['profile'] ?? null;
+        $formatteddate = userdate($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
+        $name = $profileurl ? "<a href=\"{$profileurl}\">{$fullname}</a>" : $fullname;
+        $date = "<time>{$formatteddate}</time>";
+        return get_string('bynameondate', 'mod_forum', ['name' => $name, 'date' => $date]);
+    }
+}
diff --git a/mod/forum/classes/local/exporters/posts.php b/mod/forum/classes/local/exporters/posts.php
new file mode 100644 (file)
index 0000000..f00e3fc
--- /dev/null
@@ -0,0 +1,164 @@
+<?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/>.
+
+/**
+ * Posts exporter class.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\exporters;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\author as author_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\exporters\post as post_exporter;
+use core\external\exporter;
+use renderer_base;
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Posts exporter class.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class posts extends exporter {
+    /** @var post_entity[] $posts List of posts to export */
+    private $posts;
+    /** @var author_entity[] $authorsbyid List of authors for the posts indexed by author id */
+    private $authorsbyid;
+    /** @var array $attachmentsbypostid List of attachments indexed by post id */
+    private $attachmentsbypostid;
+    /** @var array $groupsbyauthorid List of author's groups indexed by author id */
+    private $groupsbyauthorid;
+    /** @var array $tagsbypostid List of tags indexed by post id */
+    private $tagsbypostid;
+    /** @var array $ratingbypostid List of ratings indexed by post id */
+    private $ratingbypostid;
+
+    /**
+     * Constructor.
+     *
+     * @param post_entity[] $posts List of posts to export
+     * @param author_entity[] $authorsbyid List of authors for the posts indexed by author id
+     * @param array $attachmentsbypostid List of attachments indexed by post id
+     * @param array $groupsbyauthorid List of author's groups indexed by author id
+     * @param array $tagsbypostid List of tags indexed by post id
+     * @param array $ratingbypostid List of ratings indexed by post id
+     * @param array $related The related objects for exporting
+     */
+    public function __construct(
+        array $posts,
+        array $authorsbyid = [],
+        array $attachmentsbypostid = [],
+        array $groupsbyauthorid = [],
+        array $tagsbypostid = [],
+        array $ratingbypostid = [],
+        array $related = []
+    ) {
+        $this->posts = $posts;
+        $this->authorsbyid = $authorsbyid;
+        $this->attachmentsbypostid = $attachmentsbypostid;
+        $this->groupsbyauthorid = $groupsbyauthorid;
+        $this->tagsbypostid = $tagsbypostid;
+        $this->ratingbypostid = $ratingbypostid;
+        return parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'posts' => [
+                'type' => post_exporter::read_properties_definition(),
+                'multiple' => true
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $related = $this->related;
+        $authorsbyid = $this->authorsbyid;
+        $attachmentsbypostid = $this->attachmentsbypostid;
+        $groupsbyauthorid = $this->groupsbyauthorid;
+        $tagsbypostid = $this->tagsbypostid;
+        $ratingbypostid = $this->ratingbypostid;
+        $exportedposts = array_map(
+            function($post) use (
+                $related,
+                $authorsbyid,
+                $attachmentsbypostid,
+                $groupsbyauthorid,
+                $tagsbypostid,
+                $ratingbypostid,
+                $output
+            ) {
+                $authorid = $post->get_author_id();
+                $postid = $post->get_id();
+                $author = isset($authorsbyid[$authorid]) ? $authorsbyid[$authorid] : [];
+                $attachments = isset($attachmentsbypostid[$postid]) ? $attachmentsbypostid[$postid] : [];
+                $authorgroups = isset($groupsbyauthorid[$authorid]) ? $groupsbyauthorid[$authorid] : [];
+                $tags = isset($tagsbypostid[$postid]) ? $tagsbypostid[$postid] : [];
+                $rating = isset($ratingbypostid[$postid]) ? $ratingbypostid[$postid] : null;
+                $exporter = new post_exporter($post, array_merge($related, [
+                    'author' => $author,
+                    'attachments' => $attachments,
+                    'authorgroups' => $authorgroups,
+                    'tags' => $tags,
+                    'rating' => $rating
+                ]));
+                return $exporter->export($output);
+            },
+            $this->posts
+        );
+
+        return [
+            'posts' => $exportedposts
+        ];
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'capabilitymanager' => 'mod_forum\local\managers\capability',
+            'urlfactory' => 'mod_forum\local\factories\url',
+            'forum' => 'mod_forum\local\entities\forum',
+            'discussion' => 'mod_forum\local\entities\discussion',
+            'readreceiptcollection' => 'mod_forum\local\entities\post_read_receipt_collection?',
+            'user' => 'stdClass',
+            'context' => 'context',
+            'includehtml' => 'bool'
+        ];
+    }
+}
diff --git a/mod/forum/classes/local/factories/builder.php b/mod/forum/classes/local/factories/builder.php
new file mode 100644 (file)
index 0000000..20da60c
--- /dev/null
@@ -0,0 +1,111 @@
+<?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/>.
+
+/**
+ * Builder factory.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\factories;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\builders\exported_posts as exported_posts_builder;
+use mod_forum\local\builders\exported_discussion_summaries as exported_discussion_summaries_builder;
+use mod_forum\local\factories\vault as vault_factory;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\exporter as exporter_factory;
+use mod_forum\local\factories\manager as manager_factory;
+use renderer_base;
+
+/**
+ * Builder factory to construct any builders for forum.
+ *
+ * See:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/SimpleFactory/README.html
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class builder {
+    /** @var legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory */
+    private $legacydatamapperfactory;
+    /** @var exporter_factory $exporterfactory Exporter factory */
+    private $exporterfactory;
+    /** @var vault_factory $vaultfactory Vault factory */
+    private $vaultfactory;
+    /** @var manager_factory $managerfactory Manager factory */
+    private $managerfactory;
+    /** @var renderer_base $rendererbase Renderer base */
+    private $rendererbase;
+
+    /**
+     * Constructor.
+     *
+     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
+     * @param exporter_factory $exporterfactory Exporter factory
+     * @param vault_factory $vaultfactory Vault factory
+     * @param manager_factory $managerfactory Manager factory
+     * @param renderer_base $rendererbase Renderer base
+     */
+    public function __construct(
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        exporter_factory $exporterfactory,
+        vault_factory $vaultfactory,
+        manager_factory $managerfactory,
+        renderer_base $rendererbase
+    ) {
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->exporterfactory = $exporterfactory;
+        $this->vaultfactory = $vaultfactory;
+        $this->managerfactory = $managerfactory;
+        $this->rendererbase = $rendererbase;
+    }
+
+
+    /**
+     * Get an instance of the exported posts builder.
+     *
+     * @return exported_posts_builder
+     */
+    public function get_exported_posts_builder() : exported_posts_builder {
+        return new exported_posts_builder(
+            $this->rendererbase,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $this->managerfactory->get_rating_manager()
+        );
+    }
+
+    /**
+     * Get an instance of the exported discussion summaries builder.
+     *
+     * @return exported_discussion_summaries_builder
+     */
+    public function get_exported_discussion_summaries_builder() : exported_discussion_summaries_builder {
+        return new exported_discussion_summaries_builder(
+            $this->rendererbase,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $this->managerfactory->get_rating_manager()
+        );
+    }
+}
diff --git a/mod/forum/classes/local/factories/entity.php b/mod/forum/classes/local/factories/entity.php
new file mode 100644 (file)
index 0000000..ee36f80
--- /dev/null
@@ -0,0 +1,249 @@
+<?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/>.
+
+/**
+ * Entity factory.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\factories;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\author as author_entity;
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\discussion_summary as discussion_summary_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\entities\post_read_receipt_collection as post_read_receipt_collection_entity;
+use mod_forum\local\entities\sorter as sorter_entity;
+use stdClass;
+use context;
+use cm_info;
+use user_picture;
+use moodle_url;
+
+/**
+ * Entity factory to create the forum entities.
+ *
+ * See:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/SimpleFactory/README.html
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class entity {
+    /**
+     * Create a forum entity from a stdClass (legacy forum object).
+     *
+     * @param stdClass $record The forum record
+     * @param context $context The forum module context
+     * @param stdClass $coursemodule Course module record for the forum
+     * @param stdClass $course Course the forum belongs to
+     * @return forum_entity
+     */
+    public function get_forum_from_stdclass(
+        stdClass $record,
+        context $context,
+        stdClass $coursemodule,
+        stdClass $course
+    ) : forum_entity {
+        // Note: cm_info::create loads a cm_info in the context of the current user which
+        // creates hidden dependency on the logged in user (very bad) however it's the best
+        // option to load some data we need which doesn't require the logged in user.
+        // Only use properties which do not require the logged in user.
+        $cm = \cm_info::create($coursemodule);
+
+        return new forum_entity(
+            $context,
+            $coursemodule,
+            $course,
+            // This property is a general module property that isn't affected by the logged in user.
+            $cm->effectivegroupmode,
+            $record->id,
+            $record->course,
+            $record->type,
+            $record->name,
+            $record->intro,
+            $record->introformat,
+            $record->assessed,
+            $record->assesstimestart,
+            $record->assesstimefinish,
+            $record->scale,
+            $record->maxbytes,
+            $record->maxattachments,
+            $record->forcesubscribe,
+            $record->trackingtype,
+            $record->rsstype,
+            $record->rssarticles,
+            $record->timemodified,
+            $record->warnafter,
+            $record->blockafter,
+            $record->blockperiod,
+            $record->completiondiscussions,
+            $record->completionreplies,
+            $record->completionposts,
+            $record->displaywordcount,
+            $record->lockdiscussionafter
+        );
+    }
+
+    /**
+     * Create a discussion entity from an stdClass (legacy dicussion object).
+     *
+     * @param stdClass $record Discussion record
+     * @return discussion_entity
+     */
+    public function get_discussion_from_stdclass(stdClass $record) : discussion_entity {
+        return new discussion_entity(
+            $record->id,
+            $record->course,
+            $record->forum,
+            $record->name,
+            $record->firstpost,
+            $record->userid,
+            $record->groupid,
+            $record->assessed,
+            $record->timemodified,
+            $record->usermodified,
+            $record->timestart,
+            $record->timeend,
+            $record->pinned
+        );
+    }
+
+    /**
+     * Create a post entity from an stdClass (legacy post object).
+     *
+     * @param stdClass $record The post record
+     * @return post_entity
+     */
+    public function get_post_from_stdclass(stdClass $record) : post_entity {
+        return new post_entity(
+            $record->id,
+            $record->discussion,
+            $record->parent,
+            $record->userid,
+            $record->created,
+            $record->modified,
+            $record->mailed,
+            $record->subject,
+            $record->message,
+            $record->messageformat,
+            $record->messagetrust,
+            $record->attachment,
+            $record->totalscore,
+            $record->mailnow,
+            $record->deleted
+        );
+    }
+
+    /**
+     * Create an author entity from a user record.
+     *
+     * @param stdClass $record The user record
+     * @return author_entity
+     */
+    public function get_author_from_stdclass(stdClass $record) : author_entity {
+        return new author_entity(
+            $record->id,
+            $record->picture,
+            $record->firstname,
+            $record->lastname,
+            fullname($record),
+            $record->email,
+            $record->middlename,
+            $record->firstnamephonetic,
+            $record->lastnamephonetic,
+            $record->alternatename,
+            $record->imagealt
+        );
+    }
+
+    /**
+     * Create a discussion summary enttiy from stdClasses.
+     *
+     * @param stdClass $discussion The discussion record
+     * @param stdClass $firstpost A post record for the first post in the discussion
+     * @param stdClass $firstpostauthor A user record for the author of the first post
+     * @param stdClass $latestpostauthor A user record for the author of the latest post in the discussion
+     * @return discussion_summary_entity
+     */
+    public function get_discussion_summary_from_stdclass(
+        stdClass $discussion,
+        stdClass $firstpost,
+        stdClass $firstpostauthor,
+        stdClass $latestpostauthor
+    ) : discussion_summary_entity {
+
+        $firstpostauthorentity = $this->get_author_from_stdclass($firstpostauthor);
+        return new discussion_summary_entity(
+            $this->get_discussion_from_stdclass($discussion),
+            $this->get_post_from_stdclass($firstpost, $firstpostauthorentity),
+            $firstpostauthorentity,
+            $this->get_author_from_stdclass($latestpostauthor)
+        );
+    }
+
+    /**
+     * Create a post read receipt collection entity from a list of read receipt records.
+     *
+     * @param array $records A list of read receipt records.
+     * @return post_read_receipt_collection_entity
+     */
+    public function get_post_read_receipt_collection_from_stdclasses(array $records) : post_read_receipt_collection_entity {
+        return new post_read_receipt_collection_entity($records);
+    }
+
+    /**
+     * Create a sorter entity to sort post entities.
+     *
+     * @return sorter_entity
+     */
+    public function get_posts_sorter() : sorter_entity {
+        return new sorter_entity(
+            // Get id function for a post_entity.
+            function(post_entity $post) {
+                return $post->get_id();
+            },
+            // Get parent id function for a post_entity.
+            function(post_entity $post) {
+                return $post->get_parent_id();
+            }
+        );
+    }
+
+    /**
+     * Create a sorter entity to sort exported posts.
+     *
+     * @return sorter_entity
+     */
+    public function get_exported_posts_sorter() : sorter_entity {
+        return new sorter_entity(
+            // Get id function for an exported post.
+            function(stdClass $post) {
+                return $post->id;
+            },
+            // Get parent id function for an exported post.
+            function(stdClass $post) {
+                return $post->parentid;
+            }
+        );
+    }
+}
diff --git a/mod/forum/classes/local/factories/exporter.php b/mod/forum/classes/local/factories/exporter.php
new file mode 100644 (file)
index 0000000..3d071bb
--- /dev/null
@@ -0,0 +1,247 @@
+<?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/>.
+
+/**
+ * Forum Exporter factory.
+ *
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\local\factories;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\entities\post_read_receipt_collection as post_read_receipt_collection_entity;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use mod_forum\local\factories\manager as manager_factory;
+use mod_forum\local\factories\url as url_factory;
+use mod_forum\local\exporters\forum as forum_exporter;
+use mod_forum\local\exporters\discussion as discussion_exporter;
+use mod_forum\local\exporters\discussion_summaries as discussion_summaries_exporter;
+use mod_forum\local\exporters\post as post_exporter;
+use mod_forum\local\exporters\posts as posts_exporter;
+use context;
+use rating;
+use stdClass;
+
+/**
+ * The exporter factory class used to fetch an instance of the different exporter types.
+ *
+ * See:
+ * https://designpatternsphp.readthedocs.io/en/latest/Creational/SimpleFactory/README.html
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporter {
+    /** @var legacy_data_mapper_factory The factory to fetch a legacy data mapper */
+    private $legacydatamapperfactory;
+
+    /** @var manager_factory The factory to fetch a new manager */
+    private $managerfactory;
+
+    /** @var url_factory The factory to create urls */
+    private $urlfactory;
+
+    /**
+     * Constructor for the exporter factory.
+     *
+     * @param legacy_data_mapper_factory $legacydatamapperfactory The factory to fetch a legacy data mapper instance
+     * @param manager_factory $managerfactory The factory fo fetch a manager instance
+     * @param url_factory $urlfactory The factory to create urls
+     */
+    public function __construct(
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        manager_factory $managerfactory,
+        url_factory $urlfactory
+    ) {
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->managerfactory = $managerfactory;
+        $this->urlfactory = $urlfactory;
+    }
+
+    /**
+     * Construct a new forum exporter for the specified user and forum.
+     *
+     * @param   stdClass        $user The user viewing the forum
+     * @param   forum_entity    $forum The forum being viewed
+     * @param   int             $currentgroup The group currently being viewed
+     * @return  forum_exporter
+     */
+    public function get_forum_exporter(
+        stdClass $user,
+        forum_entity $forum,
+        ?int $currentgroup
+    ) : forum_exporter {
+        return new forum_exporter($forum, [
+            'legacydatamapperfactory' => $this->legacydatamapperfactory,
+            'capabilitymanager' => $this->managerfactory->get_capability_manager($forum),
+            'urlfactory' => $this->urlfactory,
+            'user' => $user,
+            'currentgroup' => $currentgroup,
+        ]);
+    }
+
+    /**
+     * Fetch the structure of the forum exporter.
+     *
+     * @return  array
+     */
+    public static function get_forum_export_structure() : array {
+        return forum_exporter::read_properties_definition();
+    }
+
+    /**
+     * Construct a new discussion exporter for the specified user and forum discussion.
+     *
+     * @param   stdClass          $user The user viewing the forum
+     * @param   forum_entity      $forum The forum being viewed
+     * @param   discussion_entity $discussion The discussion being viewed
+     * @param   stdClass[]        $groupsbyid The list of groups in the forum
+     * @return  discussion_exporter
+     */
+    public function get_discussion_exporter(
+        stdClass $user,
+        forum_entity $forum,
+        discussion_entity $discussion,
+        array $groupsbyid = []
+    ) : discussion_exporter {
+        return new discussion_exporter($discussion, [
+            'context' => $forum->get_context(),
+            'forum' => $forum,
+            'capabilitymanager' => $this->managerfactory->get_capability_manager($forum),
+            'urlfactory' => $this->urlfactory,
+            'user' => $user,
+            'legacydatamapperfactory' => $this->legacydatamapperfactory,
+            'latestpostid' => null,
+            'groupsbyid' => $groupsbyid
+        ]);
+    }
+
+    /**
+     * Fetch the structure of the discussion exporter.
+     *
+     * @return  array
+     */
+    public static function get_discussion_export_structure() {
+        return discussion_exporter::read_properties_definition();
+    }
+
+    /**
+     * Construct a new discussion summaries exporter for the specified user and set of discussions.
+     *
+     * @param   stdClass        $user The user viewing the forum
+     * @param   forum_entity    $forum The forum being viewed
+     * @param   discussion_entity[] $discussions The set of discussions to be shown
+     * @param   stdClass[]      $groupsbyauthorid The set of groups in an associative array for each author
+     * @param   stdClass[]      $groupsbyid The set of groups in the forum in an associative array for each group
+     * @param   int[]           $discussionreplycount The number of replies for each discussion
+     * @param   int[]           $discussionunreadcount The number of unread posts for each discussion
+     * @param   int[]           $latestpostids The latest post id for each discussion
+     * @return  discussion_summaries_exporter
+     */
+    public function get_discussion_summaries_exporter(
+        stdClass $user,
+        forum_entity $forum,
+        array $discussions,
+        array $groupsbyid = [],
+        array $groupsbyauthorid = [],
+        array $discussionreplycount = [],
+        array $discussionunreadcount = [],
+        array $latestpostid = []
+    ) : discussion_summaries_exporter {
+        return new discussion_summaries_exporter(
+            $discussions,
+            $groupsbyid,
+            $groupsbyauthorid,
+            $discussionreplycount,
+            $discussionunreadcount,
+            $latestpostid,
+            [
+                'legacydatamapperfactory' => $this->legacydatamapperfactory,
+                'context' => $forum->get_context(),
+                'forum' => $forum,
+                'capabilitymanager' => $this->managerfactory->get_capability_manager($forum),
+                'urlfactory' => $this->urlfactory,
+                'user' => $user,
+            ]
+        );
+    }
+
+    /**
+     * Fetch the structure of the discussion summaries exporter.
+     *
+     * @return  array
+     */
+    public static function get_discussion_summaries_export_structure() {
+        return discussion_summaries_exporter::read_properties_definition();
+    }
+
+    /**
+     * Construct a new post exporter for the specified user and set of post.
+     *
+     * @param   stdClass        $user The user viewing the forum
+     * @param   forum_entity    $forum The forum being viewed
+     * @param   discussion_entity $discussion The discussion that the post is in
+     * @param   post_entity[]   $posts The set of posts to be exported
+     * @param   author_entity[] $authorsbyid List of authors indexed by author id
+     * @param   array           $attachmentsbypostid List of attachments for each post indexed by post id
+     * @param   array           $groupsbyauthorid List of groups for the post authors indexed by author id
+     * @param   post_read_receipt_collection_entity|null $readreceiptcollection Details of read receipts for each post
+     * @param   array           $tagsbypostid List of tags for ea