Merge branch 'mod_forum-discussion-poc' of https://github.com/ryanwyllie/moodle
authorJun Pataleta <jun@moodle.com>
Fri, 22 Mar 2019 03:04:35 +0000 (11:04 +0800)
committerJun Pataleta <jun@moodle.com>
Fri, 22 Mar 2019 03:04:35 +0000 (11:04 +0800)
130 files changed:
course/format/social/tests/behat/social_adjust_discussion_count.feature
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/output/notification.php
lib/dml/moodle_database.php
lib/dml/tests/dml_test.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
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/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/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/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

index f7216c7..a0a709e 100644 (file)
@@ -4,122 +4,124 @@ Feature: Change number of discussions displayed
   As a teacher
   I need to edit the course and change the number of sections displayed.
 
-  Background:
-    Given the following "users" exist:
-      | username | firstname | lastname | email |
-      | teacher1 | Teacher | 1 | teacher1@example.com |
-    And the following "courses" exist:
-      | fullname | shortname | category | format |
-      | Course 1 | C1 | 0 | social |
-    And the following "course enrolments" exist:
-      | user | course | role |
-      | 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 set the following fields to these values:
-      | Subject | Forum Post 10 |
-      | Message | This is forum post ten |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 9 |
-      | Message | This is forum post nine |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 8 |
-      | Message | This is forum post eight |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 7 |
-      | Message | This is forum post seven |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 6 |
-      | Message | This is forum post six |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 5 |
-      | Message | This is forum post five |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 4 |
-      | Message | This is forum post four |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 3 |
-      | Message | This is forum post three |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 2 |
-      | Message | This is forum post two |
-    And I press "Post to forum"
-    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 set the following fields to these values:
-      | Subject | Forum Post 1 |
-      | Message | This is forum post one |
-    And I press "Post to forum"
-    And I wait to be redirected
-    And I am on "Course 1" course homepage
-
-  Scenario: When number of discussions is decreased fewer discussions appear
-    Given I navigate to "Edit settings" in current page administration
-    And I set the following fields to these values:
-      | numdiscussions | 5 |
-    When I press "Save and display"
-    Then I should see "This is forum post one"
-    And I should see "This is forum post five"
-    And I should not see "This is forum post six"
-
-  Scenario: When number of discussions is decreased to less than 1 only 1 discussion should appear
-    Given I navigate to "Edit settings" in current page administration
-    And I set the following fields to these values:
-      | numdiscussions | -1 |
-    When I press "Save and display"
-    Then I should see "This is forum post one"
-    And I should not see "This is forum post two"
-    And I should not see "This is forum post ten"
-
-  Scenario: When number of discussions is increased more discussions appear
-    Given I navigate to "Edit settings" in current page administration
-    And I set the following fields to these values:
-      | numdiscussions | 9 |
-    When I press "Save and display"
-    Then I should see "This is forum post one"
-    And I should see "This is forum post five"
-    And I should see "This is forum post nine"
-    And I should not see "This is forum post ten"
+# Uncomment these tests when the forum_print_latest_discussions function is
+# deprecated in MDL-65082.
+#  Background:
+#    Given the following "users" exist:
+#      | username | firstname | lastname | email |
+#      | teacher1 | Teacher | 1 | teacher1@example.com |
+#    And the following "courses" exist:
+#      | fullname | shortname | category | format |
+#      | Course 1 | C1 | 0 | social |
+#    And the following "course enrolments" exist:
+#      | user | course | role |
+#      | 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 set the following fields to these values:
+#      | Subject | Forum Post 10 |
+#      | Message | This is forum post ten |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 9 |
+#      | Message | This is forum post nine |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 8 |
+#      | Message | This is forum post eight |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 7 |
+#      | Message | This is forum post seven |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 6 |
+#      | Message | This is forum post six |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 5 |
+#      | Message | This is forum post five |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 4 |
+#      | Message | This is forum post four |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 3 |
+#      | Message | This is forum post three |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 2 |
+#      | Message | This is forum post two |
+#    And I press "Post to forum"
+#    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 set the following fields to these values:
+#      | Subject | Forum Post 1 |
+#      | Message | This is forum post one |
+#    And I press "Post to forum"
+#    And I wait to be redirected
+#    And I am on "Course 1" course homepage
+#
+#  Scenario: When number of discussions is decreased fewer discussions appear
+#    Given I navigate to "Edit settings" in current page administration
+#    And I set the following fields to these values:
+#      | numdiscussions | 5 |
+#    When I press "Save and display"
+#    Then I should see "This is forum post one"
+#    And I should see "This is forum post five"
+#    And I should not see "This is forum post six"
+#
+#  Scenario: When number of discussions is decreased to less than 1 only 1 discussion should appear
+#    Given I navigate to "Edit settings" in current page administration
+#    And I set the following fields to these values:
+#      | numdiscussions | -1 |
+#    When I press "Save and display"
+#    Then I should see "This is forum post one"
+#    And I should not see "This is forum post two"
+#    And I should not see "This is forum post ten"
+#
+#  Scenario: When number of discussions is increased more discussions appear
+#    Given I navigate to "Edit settings" in current page administration
+#    And I set the following fields to these values:
+#      | numdiscussions | 9 |
+#    When I press "Save and display"
+#    Then I should see "This is forum post one"
+#    And I should see "This is forum post five"
+#    And I should see "This is forum post nine"
+#    And I should not see "This is forum post ten"
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 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 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);
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..f7d246b
--- /dev/null
@@ -0,0 +1,146 @@
+<?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],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * 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),
+            ],
+        ];
+    }
+
+    /**
+     * 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 each post indexed by post id
+     * @param   rating[]        $ratingbypostid List of ratings for each post indexed by post id
+     * @param   bool            $includehtml Include some pre-constructed HTML in the export
+     * @return  post_exporter
+     */
+    public function get_posts_exporter(
+        stdClass $user,
+        forum_entity $forum,
+        discussion_entity $discussion,
+        array $posts,
+        array $authorsbyid = [],
+        array $attachmentsbypostid = [],
+        array $groupsbyauthorid = [],
+        post_read_receipt_collection_entity $readreceiptcollection = null,
+        array $tagsbypostid = [],
+        array $ratingbypostid = [],
+        bool $includehtml = false
+    ) : posts_exporter {
+        return new posts_exporter($posts, $authorsbyid, $attachmentsbypostid, $groupsbyauthorid, $tagsbypostid, $ratingbypostid, [
+            'capabilitymanager' => $this->managerfactory->get_capability_manager($forum),
+            'urlfactory' => $this->urlfactory,
+            'forum' => $forum,
+            'discussion' => $discussion,
+            'user' => $user,
+            'context' => $forum->get_context(),
+            'readreceiptcollection' => $readreceiptcollection,
+            'includehtml' => $includehtml
+        ]);
+    }
+
+    /**
+     * Fetch the structure of the posts exporter.
+     *
+     * @return  array
+     */
+    public static function get_posts_export_structure() {
+        return posts_exporter::read_properties_definition();
+    }
+}
diff --git a/mod/forum/classes/local/factories/legacy_data_mapper.php b/mod/forum/classes/local/factories/legacy_data_mapper.php
new file mode 100644 (file)
index 0000000..1801bdd
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Legacy data mapper 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\data_mappers\legacy\author as author_data_mapper;
+use mod_forum\local\data_mappers\legacy\discussion as discussion_data_mapper;
+use mod_forum\local\data_mappers\legacy\forum as forum_data_mapper;
+use mod_forum\local\data_mappers\legacy\post as post_data_mapper;
+
+/**
+ * Legacy data mapper factory.
+ *
+ * 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 legacy_data_mapper {
+    /**
+     * Create a legacy forum data mapper.
+     *
+     * @return forum_data_mapper
+     */
+    public function get_forum_data_mapper() : forum_data_mapper {
+        return new forum_data_mapper();
+    }
+
+    /**
+     * Create a legacy discussion data mapper.
+     *
+     * @return discussion_data_mapper
+     */
+    public function get_discussion_data_mapper() : discussion_data_mapper {
+        return new discussion_data_mapper();
+    }
+
+    /**
+     * Create a legacy post data mapper.
+     *
+     * @return post_data_mapper
+     */
+    public function get_post_data_mapper() : post_data_mapper {
+        return new post_data_mapper();
+    }
+
+    /**
+     * Create a legacy author data mapper.
+     *
+     * @return author_data_mapper
+     */
+    public function get_author_data_mapper() : author_data_mapper {
+        return new author_data_mapper();
+    }
+}
diff --git a/mod/forum/classes/local/factories/manager.php b/mod/forum/classes/local/factories/manager.php
new file mode 100644 (file)
index 0000000..9abce11
--- /dev/null
@@ -0,0 +1,81 @@
+<?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/>.
+
+/**
+ * Managers 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();
+
+global $CFG;
+require_once($CFG->dirroot . '/rating/lib.php');
+
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\managers\capability as capability_manager;
+use rating_manager;
+
+/**
+ * Managers factory.
+ *
+ * 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 manager {
+    /** @var legacy_data_mapper $legacydatamapperfactory Legacy data mapper factory */
+    private $legacydatamapperfactory;
+
+    /**
+     * Constructor.
+     *
+     * @param legacy_data_mapper $legacydatamapperfactory Legacy data mapper factory
+     */
+    public function __construct(legacy_data_mapper $legacydatamapperfactory) {
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+    }
+
+    /**
+     * Create a capability manager for the given forum.
+     *
+     * @param forum_entity $forum The forum to manage capabilities for
+     * @return capability_manager
+     */
+    public function get_capability_manager(forum_entity $forum) {
+        return new capability_manager(
+            $forum,
+            $this->legacydatamapperfactory->get_forum_data_mapper(),
+            $this->legacydatamapperfactory->get_discussion_data_mapper(),
+            $this->legacydatamapperfactory->get_post_data_mapper()
+        );
+    }
+
+    /**
+     * Create a rating manager.
+     *
+     * @return rating_manager
+     */
+    public function get_rating_manager() : rating_manager {
+        return new rating_manager();
+    }
+}
diff --git a/mod/forum/classes/local/factories/renderer.php b/mod/forum/classes/local/factories/renderer.php
new file mode 100644 (file)
index 0000000..e21f9a0
--- /dev/null
@@ -0,0 +1,494 @@
+<?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/>.
+
+/**
+ * Renderer 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\factories\vault as vault_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\builder as builder_factory;
+use mod_forum\local\factories\url as url_factory;
+use mod_forum\local\renderers\discussion as discussion_renderer;
+use mod_forum\local\renderers\discussion_list as discussion_list_renderer;
+use mod_forum\local\renderers\posts as posts_renderer;
+use moodle_page;
+use core\output\notification;
+
+/**
+ * Renderer factory.
+ *
+ * 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 renderer {
+    /** @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 entity_factory $entityfactory Entity factory */
+    private $entityfactory;
+    /** @var builder_factory $builderfactory Builder factory */
+    private $builderfactory;
+    /** @var url_factory $urlfactory URL factory */
+    private $urlfactory;
+    /** @var renderer_base $rendererbase Renderer base */
+    private $rendererbase;
+    /** @var moodle_page $page Moodle page */
+    private $page;
+
+    /**
+     * 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 entity_factory $entityfactory Entity factory
+     * @param builder_factory $builderfactory Builder factory
+     * @param url_factory $urlfactory URL factory
+     * @param moodle_page $page Moodle page
+     */
+    public function __construct(
+        legacy_data_mapper_factory $legacydatamapperfactory,
+        exporter_factory $exporterfactory,
+        vault_factory $vaultfactory,
+        manager_factory $managerfactory,
+        entity_factory $entityfactory,
+        builder_factory $builderfactory,
+        url_factory $urlfactory,
+        moodle_page $page
+    ) {
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+        $this->exporterfactory = $exporterfactory;
+        $this->vaultfactory = $vaultfactory;
+        $this->managerfactory = $managerfactory;
+        $this->entityfactory = $entityfactory;
+        $this->builderfactory = $builderfactory;
+        $this->urlfactory = $urlfactory;
+        $this->page = $page;
+        $this->rendererbase = $page->get_renderer('mod_forum');
+    }
+
+    /**
+     * Create a discussion renderer for the given forum and discussion.
+     *
+     * @param forum_entity $forum Forum the discussion belongs to
+     * @param discussion_entity $discussion Discussion to render
+     * @param int $displaymode How should the posts be formatted?
+     * @return discussion_renderer
+     */
+    public function get_discussion_renderer(
+        forum_entity $forum,
+        discussion_entity $discussion,
+        int $displaymode
+    ) : discussion_renderer {
+
+        $capabilitymanager = $this->managerfactory->get_capability_manager($forum);
+        $ratingmanager = $this->managerfactory->get_rating_manager();
+        $rendererbase = $this->rendererbase;
+
+        $baseurl = $this->urlfactory->get_discussion_view_url_from_discussion($discussion);
+        $notifications = [];
+
+        return new discussion_renderer(
+            $forum,
+            $discussion,
+            $displaymode,
+            $rendererbase,
+            $this->get_single_discussion_posts_renderer($displaymode, false),
+            $this->page,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $capabilitymanager,
+            $ratingmanager,
+            $this->entityfactory->get_exported_posts_sorter(),
+            $baseurl,
+            $notifications
+        );
+    }
+
+    /**
+     * Create a posts renderer to render posts without defined parent/reply relationships.
+     *
+     * @return posts_renderer
+     */
+    public function get_posts_renderer() : posts_renderer {
+        return new posts_renderer(
+            $this->rendererbase,
+            $this->builderfactory->get_exported_posts_builder(),
+            'mod_forum/forum_discussion_posts'
+        );
+    }
+
+    /**
+     * Create a posts renderer to render a list of posts in a single discussion.
+     *
+     * @param int|null $displaymode How should the posts be formatted?
+     * @param bool $readonly Should the posts include the actions to reply, delete, etc?
+     * @return posts_renderer
+     */
+    public function get_single_discussion_posts_renderer(int $displaymode = null, bool $readonly = false) : posts_renderer {
+        $exportedpostssorter = $this->entityfactory->get_exported_posts_sorter();
+
+        switch ($displaymode) {
+            case FORUM_MODE_THREADED:
+                $template = 'mod_forum/forum_discussion_threaded_posts';
+                break;
+            case FORUM_MODE_NESTED:
+                $template = 'mod_forum/forum_discussion_nested_posts';
+                break;
+            default;
+                $template = 'mod_forum/forum_discussion_posts';
+                break;
+        }
+
+        return new posts_renderer(
+            $this->rendererbase,
+            $this->builderfactory->get_exported_posts_builder(),
+            $template,
+            // Post process the exported posts for our template. This function will add the "replies"
+            // and "hasreplies" properties to the exported posts. It will also sort them into the
+            // reply tree structure if the display mode requires it.
+            function($exportedposts, $forums) use ($displaymode, $readonly, $exportedpostssorter) {
+                $forum = array_shift($forums);
+                $seenfirstunread = false;
+                $exportedposts = array_map(
+                    function($exportedpost) use ($forum, $readonly, $seenfirstunread) {
+                        if ($forum->get_type() == 'single' && !$exportedpost->hasparent) {
+                            // Remove the author from any posts that don't have a parent.
+                            unset($exportedpost->author);
+                        }
+
+                        $exportedpost->firstpost = false;
+                        $exportedpost->readonly = $readonly;
+                        $exportedpost->hasreplies = false;
+                        $exportedpost->replies = [];
+
+                        $exportedpost->isfirstunread = false;
+                        if (!$seenfirstunread && $exportedpost->unread) {
+                            $exportedpost->isfirstunread = true;
+                            $seenfirstunread = true;
+                        }
+
+                        return $exportedpost;
+                    },
+                    $exportedposts
+                );
+
+                if ($displaymode === FORUM_MODE_NESTED || $displaymode === FORUM_MODE_THREADED) {
+                    $sortedposts = $exportedpostssorter->sort_into_children($exportedposts);
+                    $sortintoreplies = function($nestedposts) use (&$sortintoreplies) {
+                        return array_map(function($postdata) use (&$sortintoreplies) {
+                            [$post, $replies] = $postdata;
+                            $post->replies = $sortintoreplies($replies);
+                            $post->hasreplies = !empty($post->replies);
+                            return $post;
+                        }, $nestedposts);
+                    };
+                    // Set the "replies" property on the exported posts.
+                    $exportedposts = $sortintoreplies($sortedposts);
+                } else if ($displaymode === FORUM_MODE_FLATNEWEST || $displaymode === FORUM_MODE_FLATOLDEST) {
+                    $exportedfirstpost = array_shift($exportedposts);
+                    $exportedfirstpost->replies = $exportedposts;
+                    $exportedfirstpost->hasreplies = true;
+                    $exportedposts = [$exportedfirstpost];
+                }
+
+                if (!empty($exportedposts)) {
+                    // Need to identify the first post so that we can use it in behat tests.
+                    $exportedposts[0]->firstpost = true;
+                }
+
+                return $exportedposts;
+            }
+        );
+    }
+
+    /**
+     * Create a posts renderer to render posts in the forum search results.
+     *
+     * @param string[] $searchterms The search terms to be highlighted in the posts
+     * @return posts_renderer
+     */
+    public function get_posts_search_results_renderer(array $searchterms) : posts_renderer {
+        $urlfactory = $this->urlfactory;
+
+        return new posts_renderer(
+            $this->rendererbase,
+            $this->builderfactory->get_exported_posts_builder(),
+            'mod_forum/forum_posts_with_context_links',
+            // Post process the exported posts to add the highlighting of the search terms to the post
+            // and also the additional context links in the subject.
+            function($exportedposts, $forumsbyid, $discussionsbyid) use ($searchterms, $urlfactory) {
+                $highlightwords = implode(' ', $searchterms);
+
+                return array_map(
+                    function($exportedpost) use (
+                        $forumsbyid,
+                        $discussionsbyid,
+                        $searchterms,
+                        $highlightwords,
+                        $urlfactory
+                    ) {
+                        $discussion = $discussionsbyid[$exportedpost->discussionid];
+                        $forum = $forumsbyid[$discussion->get_forum_id()];
+
+                        $viewdiscussionurl = $urlfactory->get_discussion_view_url_from_discussion($discussion);
+                        $exportedpost->urls['viewforum'] = $urlfactory->get_forum_view_url_from_forum($forum)->out(false);
+                        $exportedpost->urls['viewdiscussion'] = $viewdiscussionurl->out(false);
+                        $exportedpost->subject = highlight($highlightwords, $exportedpost->subject);
+                        $exportedpost->forumname = format_string($forum->get_name(), true);
+                        $exportedpost->discussionname = highlight($highlightwords, format_string($discussion->get_name(), true));
+                        $exportedpost->showdiscussionname = $forum->get_type() != 'single';
+
+                        // Identify search terms only found in HTML markup, and add a warning about them to
+                        // the start of the message text. This logic was copied exactly as is from the previous
+                        // implementation.
+                        $missingterms = '';
+                        $exportedpost->message = highlight(
+                            $highlightwords,
+                            $exportedpost->message,
+                            0,
+                            '<fgw9sdpq4>',
+                            '</fgw9sdpq4>'
+                        );
+
+                        foreach ($searchterms as $searchterm) {
+                            if (
+                                preg_match("/$searchterm/i", $exportedpost->message) &&
+                                !preg_match('/<fgw9sdpq4>' . $searchterm . '<\/fgw9sdpq4>/i', $exportedpost->message)
+                            ) {
+                                $missingterms .= " $searchterm";
+                            }
+                        }
+
+                        $exportedpost->message = str_replace('<fgw9sdpq4>', '<span class="highlight">', $exportedpost->message);
+                        $exportedpost->message = str_replace('</fgw9sdpq4>', '</span>', $exportedpost->message);
+
+                        if ($missingterms) {
+                            $strmissingsearchterms = get_string('missingsearchterms', 'forum');
+                            $exportedpost->message = '<p class="highlight2">' . $strmissingsearchterms . ' '
+                                . $missingterms . '</p>' . $exportedpost->message;
+                        }
+
+                        return $exportedpost;
+                    },
+                    $exportedposts
+                );
+            }
+        );
+    }
+
+    /**
+     * Create a posts renderer to render posts in mod/forum/user.php.
+     *
+     * @param bool $addlinkstocontext Should links to the course, forum, and discussion be included?
+     * @return posts_renderer
+     */
+    public function get_user_forum_posts_report_renderer(bool $addlinkstocontext) : posts_renderer {
+        $urlfactory = $this->urlfactory;
+
+        return new posts_renderer(
+            $this->rendererbase,
+            $this->builderfactory->get_exported_posts_builder(),
+            'mod_forum/forum_posts_with_context_links',
+            function($exportedposts, $forumsbyid, $discussionsbyid) use ($urlfactory, $addlinkstocontext) {
+
+                return array_map(function($exportedpost) use ($forumsbyid, $discussionsbyid, $addlinkstocontext, $urlfactory) {
+                    $discussion = $discussionsbyid[$exportedpost->discussionid];
+                    $forum = $forumsbyid[$discussion->get_forum_id()];
+                    $courserecord = $forum->get_course_record();
+
+                    if ($addlinkstocontext) {
+                        $viewdiscussionurl = $urlfactory->get_discussion_view_url_from_discussion($discussion);
+                        $exportedpost->urls['viewforum'] = $urlfactory->get_forum_view_url_from_forum($forum)->out(false);
+                        $exportedpost->urls['viewdiscussion'] = $viewdiscussionurl->out(false);
+                        $exportedpost->urls['viewcourse'] = $urlfactory->get_course_url_from_forum($forum)->out(false);
+                    }
+
+                    $exportedpost->forumname = format_string($forum->get_name(), true);
+                    $exportedpost->discussionname = format_string($discussion->get_name(), true);
+                    $exportedpost->coursename = format_string($courserecord->shortname, true);
+                    $exportedpost->showdiscussionname = $forum->get_type() != 'single';
+
+                    return $exportedpost;
+                }, $exportedposts);
+            }
+        );
+    }
+
+    /**
+     * Create a standard type discussion list renderer.
+     *
+     * @param forum_entity $forum The forum that the discussions belong to
+     * @return discussion_list_renderer
+     */
+    public function get_discussion_list_renderer(
+        forum_entity $forum
+    ) : discussion_list_renderer {
+
+        $capabilitymanager = $this->managerfactory->get_capability_manager($forum);
+        $rendererbase = $this->rendererbase;
+        $notifications = [];
+
+        return new discussion_list_renderer(
+            $forum,
+            $rendererbase,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $this->builderfactory,
+            $capabilitymanager,
+            $this->urlfactory,
+            $notifications,
+            function($discussions, $user, $forum) {
+
+                $exporteddiscussionsummarybuilder = $this->builderfactory->get_exported_discussion_summaries_builder();
+                return $exporteddiscussionsummarybuilder->build(
+                    $user,
+                    $forum,
+                    $discussions
+                );
+            }
+        );
+    }
+
+    /**
+     * Create a blog type discussion list renderer.
+     *
+     * @param forum_entity $forum The forum that the discussions belong to
+     * @return discussion_list_renderer
+     */
+    public function get_blog_discussion_list_renderer(
+        forum_entity $forum
+    ) : discussion_list_renderer {
+
+        $capabilitymanager = $this->managerfactory->get_capability_manager($forum);
+        $rendererbase = $this->rendererbase;
+        $notifications = [];
+
+        return new discussion_list_renderer(
+            $forum,
+            $rendererbase,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $this->builderfactory,
+            $capabilitymanager,
+            $this->urlfactory,
+            $notifications,
+            function($discussions, $user, $forum) {
+                $exportedpostsbuilder = $this->builderfactory->get_exported_posts_builder();
+                $discussionentries = [];
+                $postentries = [];
+                foreach ($discussions as $discussion) {
+                    $discussionentries[] = $discussion->get_discussion();
+                    $discussionentriesids[] = $discussion->get_discussion()->get_id();
+                    $postentries[] = $discussion->get_first_post();
+                }
+
+                $exportedposts['posts'] = $exportedpostsbuilder->build(
+                    $user,
+                    [$forum],
+                    $discussionentries,
+                    $postentries
+                );
+
+                $postvault = $this->vaultfactory->get_post_vault();
+                $discussionrepliescount = $postvault->get_reply_count_for_discussion_ids($discussionentriesids);
+
+                array_walk($exportedposts['posts'], function($post) use ($discussionrepliescount) {
+                    $post->discussionrepliescount = $discussionrepliescount[$post->discussionid] ?? 0;
+                    // TODO: Find a better solution due to language differences when defining the singular and plural form.
+                    $post->isreplyplural = $post->discussionrepliescount != 1 ? true : false;
+                });
+
+                $exportedposts['state']['hasdiscussions'] = $exportedposts['posts'] ? true : false;
+
+                return $exportedposts;
+            }
+        );
+    }
+
+    /**
+     * Create a single type discussion list renderer.
+     *
+     * @param forum_entity $forum Forum the discussion belongs to
+     * @param discussion_entity $discussion The discussion entity
+     * @param bool $hasmultiplediscussions Whether the forum has multiple discussions (more than one)
+     * @param int $displaymode How should the posts be formatted?
+     * @return discussion_renderer
+     */
+    public function get_single_discussion_list_renderer(
+        forum_entity $forum,
+        discussion_entity $discussion,
+        bool $hasmultiplediscussions,
+        int $displaymode
+    ) : discussion_renderer {
+
+        $capabilitymanager = $this->managerfactory->get_capability_manager($forum);
+        $ratingmanager = $this->managerfactory->get_rating_manager();
+        $rendererbase = $this->rendererbase;
+
+        $cmid = $forum->get_course_module_record()->id;
+        $baseurl = $this->urlfactory->get_forum_view_url_from_course_module_id($cmid);
+        $notifications = array();
+
+        if ($hasmultiplediscussions) {
+            $notifications[] = (new notification(get_string('warnformorepost', 'forum')))
+                ->set_show_closebutton(true);
+        }
+
+        return new discussion_renderer(
+            $forum,
+            $discussion,
+            $displaymode,
+            $rendererbase,
+            $this->get_single_discussion_posts_renderer($displaymode, false),
+            $this->page,
+            $this->legacydatamapperfactory,
+            $this->exporterfactory,
+            $this->vaultfactory,
+            $capabilitymanager,
+            $ratingmanager,
+            $this->entityfactory->get_exported_posts_sorter(),
+            $baseurl,
+            $notifications
+        );
+    }
+}
diff --git a/mod/forum/classes/local/factories/url.php b/mod/forum/classes/local/factories/url.php
new file mode 100644 (file)
index 0000000..5268b9c
--- /dev/null
@@ -0,0 +1,460 @@
+<?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/>.
+
+/**
+ * A URL factory for the forum.
+ *
+ * @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\factories;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\entities\author as author_entity;
+use mod_forum\local\entities\forum as forum_entity;
+use mod_forum\local\entities\discussion as discussion_entity;
+use mod_forum\local\entities\post as post_entity;
+use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
+use moodle_url;
+use stored_file;
+use user_picture;
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * A URL factory for the forum.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class url {
+    /** @var legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory */
+    private $legacydatamapperfactory;
+
+    /**
+     * Constructor.
+     *
+     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
+     */
+    public function __construct(legacy_data_mapper_factory $legacydatamapperfactory) {
+        $this->legacydatamapperfactory = $legacydatamapperfactory;
+    }
+
+    /**
+     * Get the course url from the given course id.
+     *
+     * @param int $courseid The course id
+     * @return moodle_url
+     */
+    public function get_course_url_from_courseid(int $courseid) : moodle_url {
+        return new moodle_url('/course/view.php', [
+            'id' => $courseid,
+        ]);
+    }
+
+    /**
+     * Get the course url from the given forum entity.
+     *
+     * @param forum_entity $forum The forum entity
+     * @return moodle_url
+     */
+    public function get_course_url_from_forum(forum_entity $forum) : moodle_url {
+        return $this->get_course_url_from_courseid($forum->get_course_id());
+    }
+
+    /**
+     * Get the create discussion url for the given forum.
+     *
+     * @param forum_entity $forum The forum entity
+     * @return moodle_url
+     */
+    public function get_discussion_create_url(forum_entity $forum) : moodle_url {
+        return new moodle_url('/mod/forum/post.php', [
+            'forum' => $forum->get_id(),
+        ]);
+    }
+
+    /**
+     * Get the view forum url for the given forum and optionally a page number.
+     *
+     * @param forum_entity $forum The forum entity
+     * @param int|null $pageno The page number
+     * @return moodle_url
+     */
+    public function get_forum_view_url_from_forum(forum_entity $forum, ?int $pageno = null) : moodle_url {
+        return $this->get_forum_view_url_from_course_module_id($forum->get_course_module_record()->id, $pageno);
+    }
+
+    /**
+     * Get the view forum url for the given course module id and optionally a page number.
+     *
+     * @param int $coursemoduleid The course module id
+     * @param int|null $pageno The page number
+     * @return moodle_url
+     */
+    public function get_forum_view_url_from_course_module_id(int $coursemoduleid, ?int $pageno = null) : moodle_url {
+        $url = new moodle_url('/mod/forum/view.php', [
+            'id' => $coursemoduleid,
+        ]);
+
+        if (null !== $pageno) {
+            $url->param('page', $pageno);
+        }
+
+        return $url;
+    }
+
+    /**
+     * Get the view discussion url from the given discussion id.
+     *
+     * @param int $discussionid The discussion id
+     * @return moodle_url
+     */
+    public function get_discussion_view_url_from_discussion_id(int $discussionid) : moodle_url {
+        return new moodle_url('/mod/forum/discuss.php', [
+            'd' => $discussionid
+        ]);
+    }
+
+    /**
+     * Get the view discussion url from the given discussion.
+     *
+     * @param discussion_entity $discussion The discussion
+     * @return moodle_url
+     */
+    public function get_discussion_view_url_from_discussion(discussion_entity $discussion) : moodle_url {
+        return $this->get_discussion_view_url_from_discussion_id($discussion->get_id());
+    }
+
+    /**
+     * Get the url to view the first unread post in a discussion.
+     *
+     * @param discussion_entity $discussion The discussion
+     * @return moodle_url
+     */
+    public function get_discussion_view_first_unread_post_url_from_discussion(discussion_entity $discussion) {
+        $viewurl = $this->get_discussion_view_url_from_discussion_id($discussion->get_id());
+        $viewurl->set_anchor('unread');
+
+        return $viewurl;
+    }
+
+    /**
+     * Get the url to view the latest post in a discussion.
+     *
+     * @param discussion_entity $discussion The discussion
+     * @param int|null $latestpost The id of the latest post
+     * @return moodle_url
+     */
+    public function get_discussion_view_latest_post_url_from_discussion(discussion_entity $discussion, ?int $latestpost) {
+        $viewurl = $this->get_discussion_view_url_from_discussion_id($discussion->get_id());
+        if (null === $latestpost) {
+            return $viewurl;
+        } else {
+            return new moodle_url($viewurl, ['parent' => $latestpost]);
+        }
+    }
+
+    /**
+     * Get the url to view a discussion from a post.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_discussion_view_url_from_post(post_entity $post) : moodle_url {
+        return $this->get_discussion_view_url_from_discussion_id($post->get_discussion_id());
+    }
+
+    /**
+     * Get the url to view a discussion from a discussion id and post id.
+     *
+     * @param int $discussionid The discussion id
+     * @param int $postid The post id
+     * @return moodle_url
+     */
+    public function get_view_post_url_from_post_id(int $discussionid, int $postid) : moodle_url {
+        $url = $this->get_discussion_view_url_from_discussion_id($discussionid);
+        $url->set_anchor('p' . $postid);
+        return $url;
+    }
+
+    /**
+     * Get the url to view a post in the context of the rest of the discussion.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_view_post_url_from_post(post_entity $post) : moodle_url {
+        return $this->get_view_post_url_from_post_id($post->get_discussion_id(), $post->get_id());
+    }
+
+    /**
+     * Get the url to view a post and it's replies in isolation without the rest of the
+     * discussion.
+     *
+     * @param int $discussionid The discussion id
+     * @param int $postid The post id
+     * @return moodle_url
+     */
+    public function get_view_isolated_post_url_from_post_id(int $discussionid, int $postid) : moodle_url {
+        $url = $this->get_discussion_view_url_from_discussion_id($discussionid);
+        $url->params(['parent' => $postid]);
+        return $url;
+    }
+
+    /**
+     * Get the url to view a post and it's replies in isolation without the rest of the
+     * discussion.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_view_isolated_post_url_from_post(post_entity $post) : moodle_url {
+        return $this->get_view_isolated_post_url_from_post_id($post->get_discussion_id(), $post->get_id());
+    }
+
+    /**
+     * Get the url to edit a post.
+     *
+     * @param forum_entity $forum The forum the post belongs to
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_edit_post_url_from_post(forum_entity $forum, post_entity $post) : moodle_url {
+        if ($forum->get_type() == 'single') {
+            return new moodle_url('/course/modedit.php', [
+                'update' => $forum->get_course_module_record()->id,
+                'sesskey' => sesskey(),
+                'return' => 1
+            ]);
+        } else {
+            return new moodle_url('/mod/forum/post.php', [
+                'edit' => $post->get_id()
+            ]);
+        }
+    }
+
+    /**
+     * Get the url to split a discussion at a post.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_split_discussion_at_post_url_from_post(post_entity $post) : moodle_url {
+        return new moodle_url('/mod/forum/post.php', [
+            'prune' => $post->get_id()
+        ]);
+    }
+
+    /**
+     * Get the url to delete a post.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_delete_post_url_from_post(post_entity $post) : moodle_url {
+        return new moodle_url('/mod/forum/post.php', [
+            'delete' => $post->get_id()
+        ]);
+    }
+
+    /**
+     * Get the url to reply to a post.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_reply_to_post_url_from_post(post_entity $post) : moodle_url {
+        return new moodle_url('/mod/forum/post.php#mformforum', [
+            'reply' => $post->get_id()
+        ]);
+    }
+
+    /**
+     * Get the url to export (see portfolios) a post.
+     *
+     * @param post_entity $post The post
+     * @return moodle_url
+     */
+    public function get_export_post_url_from_post(post_entity $post) : ?moodle_url {
+        global $CFG;
+
+        require_once($CFG->libdir . '/portfoliolib.php');
+        $button = new \portfolio_add_button();
+        $button->set_callback_options('forum_portfolio_caller', ['postid' => $post->get_id()], 'mod_forum');
+        if ($post->has_attachments()) {
+            $button->set_formats(PORTFOLIO_FORMAT_RICHHTML);
+        } else {
+            $button->set_formats(PORTFOLIO_FORMAT_PLAINHTML);
+        }
+
+        $url = $button->to_html(PORTFOLIO_ADD_MOODLE_URL);
+        return $url ?: null;
+    }
+
+    /**
+     * Get the url to mark a post as read.
+     *
+     * @param post_entity $post The post
+     * @param int $displaymode The display mode to show the forum in after marking as read
+     * @return moodle_url
+     */
+    public function get_mark_post_as_read_url_from_post(post_entity $post, int $displaymode = FORUM_MODE_THREADED) : moodle_url {
+        $params = [
+            'd' => $post->get_discussion_id(),
+            'postid' => $post->get_id(),
+            'mark' => 'read'
+        ];
+
+        $url = new moodle_url('/mod/forum/discuss.php', $params);
+
+        if ($displaymode == FORUM_MODE_THREADED) {
+            $url->param('parent', $post->get_parent_id());
+        } else {
+            $url->set_anchor('p' . $post->get_id());
+        }
+
+        return $url;
+    }
+
+    /**
+     * Get the url to mark a post as unread.
+     *
+     * @param post_entity $post The post
+     * @param int $displaymode The display mode to show the forum in after marking as unread
+     * @return moodle_url
+     */
+    public function get_mark_post_as_unread_url_from_post(post_entity $post, int $displaymode = FORUM_MODE_THREADED) : moodle_url {
+        $params = [
+            'd' => $post->get_discussion_id(),
+            'postid' => $post->get_id(),
+            'mark' => 'unread'
+        ];
+
+        $url = new moodle_url('/mod/forum/discuss.php', $params);
+
+        if ($displaymode == FORUM_MODE_THREADED) {
+            $url->param('parent', $post->get_parent_id());
+        } else {
+            $url->set_anchor('p' . $post->get_id());
+        }
+
+        return $url;
+    }
+
+    /**
+     * Get the url to export attachments for a post.
+     *
+     * @param post_entity $post The post
+     * @param stored_file $attachment
+     * @return moodle_url|null
+     */
+    public function get_export_attachment_url_from_post_and_attachment(post_entity $post, stored_file $attachment) : ?moodle_url {
+        global $CFG;
+
+        require_once($CFG->libdir . '/portfoliolib.php');
+        $button = new \portfolio_add_button();
+        $button->set_callback_options(
+            'forum_portfolio_caller',
+            ['postid' => $post->get_id(), 'attachment' => $attachment->get_id()],
+            'mod_forum'
+        );
+        $button->set_format_by_file($attachment);
+        $url = $button->to_html(PORTFOLIO_ADD_MOODLE_URL);
+        return $url ?: null;
+    }
+
+    /**
+     * Get the url to view an author's profile.
+     *
+     * @param author_entity $author The author
+     * @return moodle_url
+     */
+    public function get_author_profile_url(author_entity $author) : moodle_url {
+        return new moodle_url('/user/view.php', [
+            'id' => $author->get_id()
+        ]);
+    }
+
+    /**
+     * Get the url to view the author's profile image.
+     *
+     * @param author_entity $author The author
+     * @return moodle_url
+     */
+    public function get_author_profile_image_url(author_entity $author) : moodle_url {
+        global $PAGE;
+
+        $datamapper = $this->legacydatamapperfactory->get_author_data_mapper();
+        $record = $datamapper->to_legacy_object($author);
+        $userpicture = new user_picture($record);
+        $userpicture->size = 2;
+
+        return $userpicture->get_url($PAGE);
+    }
+
+    /**
+     * Get the url to mark a discussion as read.
+     *
+     * @param forum_entity $forum The forum that the discussion belongs to
+     * @param discussion_entity $discussion The discussion
+     * @return moodle_url
+     */
+    public function get_mark_discussion_as_read_url_from_discussion(
+        forum_entity $forum,
+        discussion_entity $discussion
+    ) : moodle_url {
+        return new moodle_url('/mod/forum/markposts.php', [
+            'f' => $discussion->get_forum_id(),
+            'd' => $discussion->get_id(),
+            'mark' => 'read',
+            'sesskey' => sesskey(),
+            'return' => $this->get_forum_view_url_from_forum($forum)->out(),
+        ]);
+    }
+
+    /**
+     * Get the url to mark all discussions as read.
+     *
+     * @param forum_entity $forum The forum that the discussions belong to
+     * @return moodle_url
+     */
+    public function get_mark_all_discussions_as_read_url(forum_entity $forum) : moodle_url {
+        return new moodle_url('/mod/forum/markposts.php', [
+            'f' => $forum->get_id(),
+            'mark' => 'read',
+            'sesskey' => sesskey(),
+            'return' => $this->get_forum_view_url_from_forum($forum)->out(),
+        ]);
+    }
+
+    /**
+     * Get the url to subscribe to a discussion.
+     *
+     * @param discussion_entity $discussion The discussion
+     * @return moodle_url
+     */
+    public function get_discussion_subscribe_url(discussion_entity $discussion) : moodle_url {
+        return new moodle_url('/mod/forum/subscribe.php', [
+            'sesskey' => sesskey(),
+            'id' => $discussion->get_forum_id(),
+            'd' => $discussion->get_id()
+        ]);
+    }
+}
diff --git a/mod/forum/classes/local/factories/vault.php b/mod/forum/classes/local/factories/vault.php
new file mode 100644 (file)
index 0000000..a120edc
--- /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/>.
+
+/**
+ * Vault 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\factories\entity as entity_factory;
+use mod_forum\local\vaults\author as author_vault;
+use mod_forum\local\vaults\discussion as discussion_vault;
+use mod_forum\local\vaults\discussion_list as discussion_list_vault;
+use mod_forum\local\vaults\forum as forum_vault;
+use mod_forum\local\vaults\post as post_vault;
+use mod_forum\local\vaults\post_attachment as post_attachment_vault;
+use mod_forum\local\vaults\post_read_receipt_collection as post_read_receipt_collection_vault;
+use file_storage;
+use moodle_database;
+
+/**
+ * Vault factory.
+ *
+ * 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 vault {
+    /** @var entity_factory $entityfactory Entity factory */
+    private $entityfactory;
+    /** @var moodle_database $db A moodle database */
+    private $db;
+    /** @var file_storage $filestorage A file storage instance */
+    private $filestorage;
+
+    /**
+     * Constructor.
+     *
+     * @param moodle_database $db A moodle database
+     * @param entity_factory $entityfactory Entity factory
+     * @param file_storage $filestorage A file storage instance
+     */
+    public function __construct(moodle_database $db, entity_factory $entityfactory, file_storage $filestorage) {
+        $this->db = $db;
+        $this->entityfactory = $entityfactory;
+        $this->filestorage = $filestorage;
+    }
+
+    /**
+     * Create a forum vault.
+     *
+     * @return forum_vault
+     */
+    public function get_forum_vault() : forum_vault {
+        return new forum_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create a discussion vault.
+     *
+     * @return discussion_vault
+     */
+    public function get_discussion_vault() : discussion_vault {
+        return new discussion_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create a discussion list vault.
+     *
+     * @return discussion_list_vault
+     */
+    public function get_discussions_in_forum_vault() : discussion_list_vault {
+        return new discussion_list_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create a post vault.
+     *
+     * @return post_vault
+     */
+    public function get_post_vault() : post_vault {
+        return new post_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create an author vault.
+     *
+     * @return author_vault
+     */
+    public function get_author_vault() : author_vault {
+        return new author_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create a post read receipt collection vault.
+     *
+     * @return post_read_receipt_collection_vault
+     */
+    public function get_post_read_receipt_collection_vault() : post_read_receipt_collection_vault {
+        return new post_read_receipt_collection_vault(
+            $this->db,
+            $this->entityfactory
+        );
+    }
+
+    /**
+     * Create a post attachment vault.
+     *
+     * @return post_attachment_vault
+     */
+    public function get_post_attachment_vault() : post_attachment_vault {
+        return new post_attachment_vault(
+            $this->filestorage
+        );
+    }
+}
diff --git a/mod/forum/classes/local/managers/capability.php b/mod/forum/classes/local/managers/capability.php
new file mode 100644 (file)
index 0000000..47d9b81
--- /dev/null
@@ -0,0 +1,514 @@
+<?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/>.
+
+/**
+ * Capability manager for the forum.
+ *
+ * @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\managers;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_forum\local\data_mappers\legacy\forum as legacy_forum_data_mapper;
+use mod_forum\local\data_mappers\legacy\discussion as legacy_discussion_data_mapper;
+use mod_forum\local\data_mappers\legacy\post as legacy_post_data_mapper;
+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\subscriptions;
+use context;
+use context_system;
+use stdClass;
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Capability manager for the forum.
+ *
+ * Defines all the business rules for what a user can and can't do in the forum.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class capability {
+    /** @var legacy_forum_data_mapper $forumdatamapper Legacy forum data mapper */
+    private $forumdatamapper;
+    /** @var legacy_discussion_data_mapper $discussiondatamapper Legacy discussion data mapper */
+    private $discussiondatamapper;
+    /** @var legacy_post_data_mapper $postdatamapper Legacy post data mapper */
+    private $postdatamapper;
+    /** @var forum_entity $forum Forum entity */
+    private $forum;
+    /** @var stdClass $forumrecord Legacy forum record */
+    private $forumrecord;
+    /** @var context $context Module context for the forum */
+    private $context;
+
+    /**
+     * Constructor.
+     *
+     * @param forum_entity $forum The forum entity to manage capabilities for.
+     * @param legacy_forum_data_mapper $forumdatamapper Legacy forum data mapper
+     * @param legacy_discussion_data_mapper $discussiondatamapper Legacy discussion data mapper
+     * @param legacy_post_data_mapper $postdatamapper Legacy post data mapper
+     */
+    public function __construct(
+        forum_entity $forum,
+        legacy_forum_data_mapper $forumdatamapper,
+        legacy_discussion_data_mapper $discussiondatamapper,
+        legacy_post_data_mapper $postdatamapper
+    ) {
+        $this->forumdatamapper = $forumdatamapper;
+        $this->discussiondatamapper = $discussiondatamapper;
+        $this->postdatamapper = $postdatamapper;
+        $this->forum = $forum;
+        $this->forumrecord = $forumdatamapper->to_legacy_object($forum);
+        $this->context = $forum->get_context();
+    }
+
+    /**
+     * Can the user subscribe to this forum?
+     *
+     * @param stdClass $user The user to check
+     * @return bool
+     */
+    public function can_subscribe_to_forum(stdClass $user) : bool {
+        if ($this->forum->get_type() == 'single') {
+            return false;
+        }
+
+        return !is_guest($this->get_context(), $user) &&
+            subscriptions::is_subscribable($this->get_forum_record());
+    }
+
+    /**
+     * Can the user create discussions in this forum?
+     *
+     * @param stdClass $user The user to check
+     * @param int|null $groupid The current activity group id
+     * @return bool
+     */
+    public function can_create_discussions(stdClass $user, int $groupid = null) : bool {
+        if (isguestuser($user) or !isloggedin()) {
+            return false;
+        }
+
+        switch ($this->forum->get_type()) {
+            case 'news':
+                $capability = 'mod/forum:addnews';
+                break;
+            case 'qanda':
+                $capability = 'mod/forum:addquestion';
+                break;
+            default:
+                $capability = 'mod/forum:startdiscussion';
+        }
+
+        if (!has_capability($capability, $this->forum->get_context(), $user)) {
+            return