Add source code for part 5 of tutorial series
authorMatthias Schmidt <gravatronics@live.com>
Wed, 21 Apr 2021 10:38:51 +0000 (12:38 +0200)
committerMatthias Schmidt <gravatronics@live.com>
Thu, 22 Apr 2021 07:55:25 +0000 (09:55 +0200)
41 files changed:
snippets/tutorial/tutorial-series/part-5/acpMenu.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/eventListener.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/language/de.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/language/en.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/menuItem.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/objectType.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/package.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/page.xml [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/person.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/templates/personList.tpl [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts [new file with mode: 0644]
snippets/tutorial/tutorial-series/part-5/userGroupOption.xml [new file with mode: 0644]

diff --git a/snippets/tutorial/tutorial-series/part-5/acpMenu.xml b/snippets/tutorial/tutorial-series/part-5/acpMenu.xml
new file mode 100644 (file)
index 0000000..6ccf64f
--- /dev/null
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/acpMenu.xsd">
+       <import>
+               <acpmenuitem name="wcf.acp.menu.link.person">
+                       <parent>wcf.acp.menu.link.content</parent>
+               </acpmenuitem>
+               <acpmenuitem name="wcf.acp.menu.link.person.list">
+                       <controller>wcf\acp\page\PersonListPage</controller>
+                       <parent>wcf.acp.menu.link.person</parent>
+                       <permissions>admin.content.canManagePeople</permissions>
+               </acpmenuitem>
+               <acpmenuitem name="wcf.acp.menu.link.person.add">
+                       <controller>wcf\acp\form\PersonAddForm</controller>
+                       <parent>wcf.acp.menu.link.person.list</parent>
+                       <permissions>admin.content.canManagePeople</permissions>
+                       <icon>fa-plus</icon>
+               </acpmenuitem>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl b/snippets/tutorial/tutorial-series/part-5/acptemplates/personAdd.tpl
new file mode 100644 (file)
index 0000000..4cf2ec4
--- /dev/null
@@ -0,0 +1,19 @@
+{include file='header' pageTitle='wcf.acp.person.'|concat:$action}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{lang}wcf.acp.person.{$action}{/lang}</h1>
+       </div>
+       
+       <nav class="contentHeaderNavigation">
+               <ul>
+                       <li><a href="{link controller='PersonList'}{/link}" class="button"><span class="icon icon16 fa-list"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>
+                       
+                       {event name='contentHeaderNavigation'}
+               </ul>
+       </nav>
+</header>
+
+{@$form->getHtml()}
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl b/snippets/tutorial/tutorial-series/part-5/acptemplates/personList.tpl
new file mode 100644 (file)
index 0000000..71766ef
--- /dev/null
@@ -0,0 +1,75 @@
+{include file='header' pageTitle='wcf.acp.person.list'}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{lang}wcf.acp.person.list{/lang}</h1>
+       </div>
+       
+       <nav class="contentHeaderNavigation">
+               <ul>
+                       <li><a href="{link controller='PersonAdd'}{/link}" class="button"><span class="icon icon16 fa-plus"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>
+                       
+                       {event name='contentHeaderNavigation'}
+               </ul>
+       </nav>
+</header>
+
+{hascontent}
+       <div class="paginationTop">
+               {content}{pages print=true assign=pagesLinks controller="PersonList" link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder"}{/content}
+       </div>
+{/hascontent}
+
+{if $objects|count}
+       <div class="section tabularBox">
+               <table class="table jsObjectActionContainer" data-object-action-class-name="wcf\data\person\PersonAction">
+                       <thead>
+                               <tr>
+                                       <th class="columnID columnPersonID{if $sortField == 'personID'} active {@$sortOrder}{/if}" colspan="2"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=personID&sortOrder={if $sortField == 'personID' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.global.objectID{/lang}</a></th>
+                                       <th class="columnTitle columnFirstName{if $sortField == 'firstName'} active {@$sortOrder}{/if}"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=firstName&sortOrder={if $sortField == 'firstName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.person.firstName{/lang}</a></th>
+                                       <th class="columnTitle columnLastName{if $sortField == 'lastName'} active {@$sortOrder}{/if}"><a href="{link controller='PersonList'}pageNo={@$pageNo}&sortField=lastName&sortOrder={if $sortField == 'lastName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}">{lang}wcf.person.lastName{/lang}</a></th>
+                                       
+                                       {event name='columnHeads'}
+                               </tr>
+                       </thead>
+                       
+                       <tbody class="jsReloadPageWhenEmpty">
+                               {foreach from=$objects item=person}
+                                       <tr class="jsObjectActionObject" data-object-id="{@$person->getObjectID()}">
+                                               <td class="columnIcon">
+                                                       <a href="{link controller='PersonEdit' object=$person}{/link}" title="{lang}wcf.global.button.edit{/lang}" class="jsTooltip"><span class="icon icon16 fa-pencil"></span></a>
+                                                       {objectAction action="delete" objectTitle=$person->getTitle()}
+                                                       
+                                                       {event name='rowButtons'}
+                                               </td>
+                                               <td class="columnID">{#$person->personID}</td>
+                                               <td class="columnTitle columnFirstName"><a href="{link controller='PersonEdit' object=$person}{/link}">{$person->firstName}</a></td>
+                                               <td class="columnTitle columnLastName"><a href="{link controller='PersonEdit' object=$person}{/link}">{$person->lastName}</a></td>
+                                               
+                                               {event name='columns'}
+                                       </tr>
+                               {/foreach}
+                       </tbody>
+               </table>
+       </div>
+       
+       <footer class="contentFooter">
+               {hascontent}
+                       <div class="paginationBottom">
+                               {content}{@$pagesLinks}{/content}
+                       </div>
+               {/hascontent}
+               
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               <li><a href="{link controller='PersonAdd'}{/link}" class="button"><span class="icon icon16 fa-plus"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>
+                               
+                               {event name='contentFooterNavigation'}
+                       </ul>
+               </nav>
+       </footer>
+{else}
+       <p class="info">{lang}wcf.global.noItems{/lang}</p>
+{/if}
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/eventListener.xml b/snippets/tutorial/tutorial-series/part-5/eventListener.xml
new file mode 100644 (file)
index 0000000..fbbc0a5
--- /dev/null
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd">
+       <import>
+               <eventlistener name="rename@wcf\data\user\UserAction">
+                       <eventclassname>wcf\data\user\UserAction</eventclassname>
+                       <eventname>rename</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserActionRenameListener</listenerclassname>
+                       <environment>all</environment>
+               </eventlistener>
+               <eventlistener name="save@wcf\acp\form\UserMergeForm">
+                       <eventclassname>wcf\acp\form\UserMergeForm</eventclassname>
+                       <eventname>save</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserMergeListener</listenerclassname>
+                       <environment>admin</environment>
+               </eventlistener>
+               <eventlistener name="execute@wcf\system\cronjob\PruneIpAddressesCronjob">
+                       <eventclassname>wcf\system\cronjob\PruneIpAddressesCronjob</eventclassname>
+                       <eventname>execute</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonPruneIpAddressesCronjobListener</listenerclassname>
+                       <environment>all</environment>
+               </eventlistener>
+               <eventlistener name="export@wcf\acp\action\UserExportGdprAction">
+                       <eventclassname>wcf\acp\action\UserExportGdprAction</eventclassname>
+                       <eventname>export</eventname>
+                       <listenerclassname>wcf\system\event\listener\PersonUserExportGdprListener</listenerclassname>
+                       <environment>admin</environment>
+               </eventlistener>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php b/snippets/tutorial/tutorial-series/part-5/files/acp/database/install_com.woltlab.wcf.people.php
new file mode 100644 (file)
index 0000000..5d73715
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+use wcf\system\database\table\column\DefaultTrueBooleanDatabaseTableColumn;
+use wcf\system\database\table\column\IntDatabaseTableColumn;
+use wcf\system\database\table\column\NotNullInt10DatabaseTableColumn;
+use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn;
+use wcf\system\database\table\column\ObjectIdDatabaseTableColumn;
+use wcf\system\database\table\column\SmallintDatabaseTableColumn;
+use wcf\system\database\table\column\TextDatabaseTableColumn;
+use wcf\system\database\table\column\VarcharDatabaseTableColumn;
+use wcf\system\database\table\DatabaseTable;
+use wcf\system\database\table\index\DatabaseTableForeignKey;
+
+return [
+    DatabaseTable::create('wcf1_person')
+        ->columns([
+            ObjectIdDatabaseTableColumn::create('personID'),
+            NotNullVarchar255DatabaseTableColumn::create('firstName'),
+            NotNullVarchar255DatabaseTableColumn::create('lastName'),
+            NotNullInt10DatabaseTableColumn::create('informationCount')
+                ->defaultValue(0),
+            SmallintDatabaseTableColumn::create('comments')
+                ->length(5)
+                ->notNull()
+                ->defaultValue(0),
+            DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),
+        ]),
+
+    DatabaseTable::create('wcf1_person_information')
+        ->columns([
+            ObjectIdDatabaseTableColumn::create('informationID'),
+            NotNullInt10DatabaseTableColumn::create('personID'),
+            TextDatabaseTableColumn::create('information'),
+            IntDatabaseTableColumn::create('userID')
+                ->length(10),
+            NotNullVarchar255DatabaseTableColumn::create('username'),
+            VarcharDatabaseTableColumn::create('ipAddress')
+                ->length(39)
+                ->notNull(true)
+                ->defaultValue(''),
+            NotNullInt10DatabaseTableColumn::create('time'),
+        ])
+        ->foreignKeys([
+            DatabaseTableForeignKey::create()
+                ->columns(['personID'])
+                ->referencedTable('wcf1_person')
+                ->referencedColumns(['personID'])
+                ->onDelete('CASCADE'),
+            DatabaseTableForeignKey::create()
+                ->columns(['userID'])
+                ->referencedTable('wcf1_user')
+                ->referencedColumns(['userID'])
+                ->onDelete('SET NULL'),
+        ]),
+];
diff --git a/snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js b/snippets/tutorial/tutorial-series/part-5/files/js/WoltLabSuite/Core/Controller/Person.js
new file mode 100644 (file)
index 0000000..6c3b501
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * Provides the JavaScript code for the person page.
+ *
+ * @author  Matthias Schmidt
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Controller/Person
+ */
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Form/Builder/Dialog", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Ui/Notification"], function (require, exports, tslib_1, Dialog_1, Language, UiNotification) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.init = void 0;
+    Dialog_1 = tslib_1.__importDefault(Dialog_1);
+    Language = tslib_1.__importStar(Language);
+    UiNotification = tslib_1.__importStar(UiNotification);
+    let addDialog;
+    const editDialogs = new Map();
+    /**
+     * Opens the edit dialog after clicking on the edit button for a piece of information.
+     */
+    function editInformation(event) {
+        event.preventDefault();
+        const currentTarget = event.currentTarget;
+        const information = currentTarget.closest(".jsObjectActionObject");
+        const informationId = information.dataset.objectId;
+        if (!editDialogs.has(informationId)) {
+            editDialogs.set(informationId, new Dialog_1.default(`personInformationEditDialog${informationId}`, "wcf\\data\\person\\information\\PersonInformationAction", "getEditDialog", {
+                actionParameters: {
+                    informationID: informationId,
+                },
+                dialog: {
+                    title: Language.get("wcf.person.information.edit"),
+                },
+                submitActionName: "submitEditDialog",
+                successCallback(returnValues) {
+                    document.getElementById(`personInformation${returnValues.informationID}`).innerHTML =
+                        returnValues.formattedInformation;
+                    UiNotification.show(Language.get("wcf.person.information.edit.success"));
+                },
+            }));
+        }
+        editDialogs.get(informationId).open();
+    }
+    /**
+     * Initializes the JavaScript code for the person page.
+     */
+    function init(personId, options) {
+        if (options.canAddInformation) {
+            // Initialize the dialog to add new information.
+            addDialog = new Dialog_1.default("personInformationAddDialog", "wcf\\data\\person\\information\\PersonInformationAction", "getAddDialog", {
+                actionParameters: {
+                    personID: personId,
+                },
+                dialog: {
+                    title: Language.get("wcf.person.information.add"),
+                },
+                submitActionName: "submitAddDialog",
+                successCallback() {
+                    UiNotification.show(Language.get("wcf.person.information.add.success"), () => window.location.reload());
+                },
+            });
+            document.getElementById("personInformationAddButton").addEventListener("click", (event) => {
+                event.preventDefault();
+                addDialog.open();
+            });
+        }
+        document
+            .querySelectorAll(".jsEditInformation")
+            .forEach((el) => el.addEventListener("click", (ev) => editInformation(ev)));
+    }
+    exports.init = init;
+});
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonAddForm.class.php
new file mode 100644 (file)
index 0000000..565274d
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace wcf\acp\form;
+
+use wcf\data\person\PersonAction;
+use wcf\form\AbstractFormBuilderForm;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\TextFormField;
+
+/**
+ * Shows the form to create a new person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Acp\Form
+ */
+class PersonAddForm extends AbstractFormBuilderForm
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person.add';
+
+    /**
+     * @inheritDoc
+     */
+    public $formAction = 'create';
+
+    /**
+     * @inheritDoc
+     */
+    public $neededPermissions = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    public $objectActionClass = PersonAction::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $objectEditLinkController = PersonEditForm::class;
+
+    /**
+     * @inheritDoc
+     */
+    public function createForm()
+    {
+        parent::createForm();
+
+        $this->form->appendChild(
+            FormContainer::create('data')
+                ->label('wcf.global.form.data')
+                ->appendChildren([
+                    TextFormField::create('firstName')
+                        ->label('wcf.person.firstName')
+                        ->required()
+                        ->autoFocus()
+                        ->maximumLength(255),
+
+                    TextFormField::create('lastName')
+                        ->label('wcf.person.lastName')
+                        ->required()
+                        ->maximumLength(255),
+
+                    BooleanFormField::create('enableComments')
+                        ->label('wcf.person.enableComments')
+                        ->description('wcf.person.enableComments.description')
+                        ->value(true),
+                ])
+        );
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/form/PersonEditForm.class.php
new file mode 100644 (file)
index 0000000..47c2b76
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace wcf\acp\form;
+
+use wcf\data\person\Person;
+use wcf\system\exception\IllegalLinkException;
+
+/**
+ * Shows the form to edit an existing person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Acp\Form
+ */
+class PersonEditForm extends PersonAddForm
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person';
+
+    /**
+     * @inheritDoc
+     */
+    public $formAction = 'update';
+
+    /**
+     * @inheritDoc
+     */
+    public function readParameters()
+    {
+        parent::readParameters();
+
+        if (isset($_REQUEST['id'])) {
+            $this->formObject = new Person($_REQUEST['id']);
+
+            if (!$this->formObject->getObjectID()) {
+                throw new IllegalLinkException();
+            }
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/acp/page/PersonListPage.class.php
new file mode 100644 (file)
index 0000000..9d57855
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\acp\page;
+
+use wcf\data\person\PersonList;
+use wcf\page\SortablePage;
+
+/**
+ * Shows the list of people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Acp\Page
+ */
+class PersonListPage extends SortablePage
+{
+    /**
+     * @inheritDoc
+     */
+    public $activeMenuItem = 'wcf.acp.menu.link.person.list';
+
+    /**
+     * @inheritDoc
+     */
+    public $neededPermissions = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    public $objectListClassName = PersonList::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = ['personID', 'firstName', 'lastName'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/Person.class.php
new file mode 100644 (file)
index 0000000..aca4223
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledLinkObject;
+use wcf\data\person\information\PersonInformation;
+use wcf\data\person\information\PersonInformationList;
+use wcf\page\PersonPage;
+use wcf\system\request\LinkHandler;
+
+/**
+ * Represents a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person
+ *
+ * @property-read   integer     $personID   unique id of the person
+ * @property-read   string      $firstName  first name of the person
+ * @property-read   string      $lastName   last name of the person
+ * @property-read   int         $informationCount   number of pieces of information added for the person
+ * @property-read   int         $enableComments     is `1` if comments are enabled for the person, otherwise `0`
+ */
+class Person extends DatabaseObject implements ITitledLinkObject
+{
+    /**
+     * Returns the first and last name of the person if a person object is treated as a string.
+     *
+     * @return  string
+     */
+    public function __toString()
+    {
+        return $this->getTitle();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getLink()
+    {
+        return LinkHandler::getInstance()->getControllerLink(PersonPage::class, [
+            'object' => $this,
+        ]);
+    }
+
+    /**
+     * Returns all pieces of information added for the person.
+     *
+     * @return  PersonInformation[]
+     */
+    public function getInformation(): array
+    {
+        if ($this->information === null) {
+            $this->information = [];
+
+            if ($this->informationCount) {
+                $list = new PersonInformationList();
+                $list->getConditionBuilder()->add('personID = ?', [$this->getObjectID()]);
+                $list->sqlOrderBy = 'time DESC';
+                $list->readObjects();
+
+                $this->information = $list->getObjects();
+            }
+        }
+
+        return $this->information;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getTitle()
+    {
+        return $this->firstName . ' ' . $this->lastName;
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonAction.class.php
new file mode 100644 (file)
index 0000000..3f34655
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\AbstractDatabaseObjectAction;
+
+/**
+ * Executes person-related actions.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person
+ *
+ * @method  Person      create()
+ * @method  PersonEditor[]  getObjects()
+ * @method  PersonEditor    getSingleObject()
+ */
+class PersonAction extends AbstractDatabaseObjectAction
+{
+    /**
+     * @inheritDoc
+     */
+    protected $permissionsDelete = ['admin.content.canManagePeople'];
+
+    /**
+     * @inheritDoc
+     */
+    protected $requireACP = ['delete'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonEditor.class.php
new file mode 100644 (file)
index 0000000..b8d5a3c
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObjectEditor;
+
+/**
+ * Provides functions to edit people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person
+ *
+ * @method static   Person  create(array $parameters = [])
+ * @method      Person  getDecoratedObject()
+ * @mixin       Person
+ */
+class PersonEditor extends DatabaseObjectEditor
+{
+    /**
+     * @inheritDoc
+     */
+    protected static $baseClass = Person::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/PersonList.class.php
new file mode 100644 (file)
index 0000000..4e16a0d
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace wcf\data\person;
+
+use wcf\data\DatabaseObjectList;
+
+/**
+ * Represents a list of people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person
+ *
+ * @method  Person      current()
+ * @method  Person[]    getObjects()
+ * @method  Person|null search($objectID)
+ * @property    Person[]    $objects
+ */
+class PersonList extends DatabaseObjectList
+{
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformation.class.php
new file mode 100644 (file)
index 0000000..43a30b6
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObject;
+use wcf\data\person\Person;
+use wcf\data\user\UserProfile;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\cache\runtime\UserProfileRuntimeCache;
+use wcf\system\html\output\HtmlOutputProcessor;
+use wcf\system\WCF;
+
+/**
+ * Represents a piece of information for a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person\Information
+ *
+ * @property-read   int         $informationID  unique id of the information
+ * @property-read   int         $personID       id of the person the information belongs to
+ * @property-read   string      $information    information text
+ * @property-read   int|null    $userID         id of the user who added the information or `null` if the user no longer exists
+ * @property-read   string      $username       name of the user who added the information
+ * @property-read   int         $time           timestamp at which the information was created
+ */
+class PersonInformation extends DatabaseObject
+{
+    /**
+     * Returns `true` if the active user can delete this piece of information and `false` otherwise.
+     */
+    public function canDelete(): bool
+    {
+        if (
+            WCF::getUser()->userID
+            && WCF::getUser()->userID == $this->userID
+            && WCF::getSession()->getPermission('user.person.canDeleteInformation')
+        ) {
+            return true;
+        }
+
+        return WCF::getSession()->getPermission('mod.person.canDeleteInformation');
+    }
+
+    /**
+     * Returns `true` if the active user can edit this piece of information and `false` otherwise.
+     */
+    public function canEdit(): bool
+    {
+        if (
+            WCF::getUser()->userID
+            && WCF::getUser()->userID == $this->userID
+            && WCF::getSession()->getPermission('user.person.canEditInformation')
+        ) {
+            return true;
+        }
+
+        return WCF::getSession()->getPermission('mod.person.canEditInformation');
+    }
+
+    /**
+     * Returns the formatted information.
+     */
+    public function getFormattedInformation(): string
+    {
+        $processor = new HtmlOutputProcessor();
+        $processor->process(
+            $this->information,
+            'com.woltlab.wcf.people.information',
+            $this->informationID
+        );
+
+        return $processor->getHtml();
+    }
+
+    /**
+     * Returns the person the information belongs to.
+     */
+    public function getPerson(): Person
+    {
+        return PersonRuntimeCache::getInstance()->getObject($this->personID);
+    }
+
+    /**
+     * Returns the user profile of the user who added the information.
+     */
+    public function getUserProfile(): UserProfile
+    {
+        if ($this->userID) {
+            return UserProfileRuntimeCache::getInstance()->getObject($this->userID);
+        } else {
+            return UserProfile::getGuestUserProfile($this->username);
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationAction.class.php
new file mode 100644 (file)
index 0000000..d6f74a9
--- /dev/null
@@ -0,0 +1,303 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\AbstractDatabaseObjectAction;
+use wcf\data\person\PersonAction;
+use wcf\data\person\PersonEditor;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\event\EventHandler;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\exception\UserInputException;
+use wcf\system\form\builder\container\wysiwyg\WysiwygFormContainer;
+use wcf\system\form\builder\DialogFormDocument;
+use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\WCF;
+
+/**
+ * Executes person information-related actions.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person\Information
+ *
+ * @method  PersonInformationEditor[]   getObjects()
+ * @method  PersonInformationEditor     getSingleObject()
+ */
+class PersonInformationAction extends AbstractDatabaseObjectAction
+{
+    /**
+     * @var DialogFormDocument
+     */
+    public $dialog;
+
+    /**
+     * @var PersonInformation
+     */
+    public $information;
+
+    /**
+     * @return  PersonInformation
+     */
+    public function create()
+    {
+        if (!isset($this->parameters['data']['time'])) {
+            $this->parameters['data']['time'] = TIME_NOW;
+        }
+        if (!isset($this->parameters['data']['userID'])) {
+            $this->parameters['data']['userID'] = WCF::getUser()->userID;
+            $this->parameters['data']['username'] = WCF::getUser()->username;
+        }
+
+        if (LOG_IP_ADDRESS) {
+            if (!isset($this->parameters['data']['ipAddress'])) {
+                $this->parameters['data']['ipAddress'] = WCF::getSession()->ipAddress;
+            }
+        } else {
+            unset($this->parameters['data']['ipAddress']);
+        }
+
+        if (!empty($this->parameters['information_htmlInputProcessor'])) {
+            /** @var HtmlInputProcessor $htmlInputProcessor */
+            $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];
+            $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();
+        }
+
+        /** @var PersonInformation $information */
+        $information = parent::create();
+
+        (new PersonAction([$information->personID], 'update', [
+            'counters' => [
+                'informationCount' => 1,
+            ],
+        ]))->executeAction();
+
+        return $information;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function update()
+    {
+        if (!empty($this->parameters['information_htmlInputProcessor'])) {
+            /** @var HtmlInputProcessor $htmlInputProcessor */
+            $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];
+            $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();
+        }
+
+        parent::update();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function validateDelete()
+    {
+        if (empty($this->objects)) {
+            $this->readObjects();
+
+            if (empty($this->objects)) {
+                throw new UserInputException('objectIDs');
+            }
+        }
+
+        foreach ($this->getObjects() as $informationEditor) {
+            if (!$informationEditor->canDelete()) {
+                throw new PermissionDeniedException();
+            }
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete()
+    {
+        $deleteCount = parent::delete();
+
+        if (!$deleteCount) {
+            return $deleteCount;
+        }
+
+        $counterUpdates = [];
+        foreach ($this->getObjects() as $informationEditor) {
+            if (!isset($counterUpdates[$informationEditor->personID])) {
+                $counterUpdates[$informationEditor->personID] = 0;
+            }
+
+            $counterUpdates[$informationEditor->personID]--;
+        }
+
+        WCF::getDB()->beginTransaction();
+        foreach ($counterUpdates as $personID => $counterUpdate) {
+            (new PersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters([
+                'informationCount' => $counterUpdate,
+            ]);
+        }
+        WCF::getDB()->commitTransaction();
+
+        return $deleteCount;
+    }
+
+    /**
+     * Validates the `getAddDialog` action.
+     */
+    public function validateGetAddDialog(): void
+    {
+        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);
+
+        $this->readInteger('personID');
+        if (PersonRuntimeCache::getInstance()->getObject($this->parameters['personID']) === null) {
+            throw new UserInputException('personID');
+        }
+    }
+
+    /**
+     * Returns the data to show the dialog to add a new piece of information on a person.
+     *
+     * @return  string[]
+     */
+    public function getAddDialog(): array
+    {
+        $this->buildDialog();
+
+        return [
+            'dialog' => $this->dialog->getHtml(),
+            'formId' => $this->dialog->getId(),
+        ];
+    }
+
+    /**
+     * Validates the `submitAddDialog` action.
+     */
+    public function validateSubmitAddDialog(): void
+    {
+        $this->validateGetAddDialog();
+
+        $this->buildDialog();
+        $this->dialog->requestData($_POST['parameters']['data'] ?? []);
+        $this->dialog->readValues();
+        $this->dialog->validate();
+    }
+
+    /**
+     * Creates a new piece of information on a person after submitting the dialog.
+     *
+     * @return  string[]
+     */
+    public function submitAddDialog(): array
+    {
+        // If there are any validation errors, show the form again.
+        if ($this->dialog->hasValidationErrors()) {
+            return [
+                'dialog' => $this->dialog->getHtml(),
+                'formId' => $this->dialog->getId(),
+            ];
+        }
+
+        (new static([], 'create', \array_merge($this->dialog->getData(), [
+            'data' => [
+                'personID' => $this->parameters['personID'],
+            ],
+        ])))->executeAction();
+
+        return [];
+    }
+
+    /**
+     * Validates the `getEditDialog` action.
+     */
+    public function validateGetEditDialog(): void
+    {
+        WCF::getSession()->checkPermissions(['user.person.canAddInformation']);
+
+        $this->readInteger('informationID');
+        $this->information = new PersonInformation($this->parameters['informationID']);
+        if (!$this->information->getObjectID()) {
+            throw new UserInputException('informationID');
+        }
+        if (!$this->information->canEdit()) {
+            throw new IllegalLinkException();
+        }
+    }
+
+    /**
+     * Returns the data to show the dialog to edit a piece of information on a person.
+     *
+     * @return  string[]
+     */
+    public function getEditDialog(): array
+    {
+        $this->buildDialog();
+        $this->dialog->updatedObject($this->information);
+
+        return [
+            'dialog' => $this->dialog->getHtml(),
+            'formId' => $this->dialog->getId(),
+        ];
+    }
+
+    /**
+     * Validates the `submitEditDialog` action.
+     */
+    public function validateSubmitEditDialog(): void
+    {
+        $this->validateGetEditDialog();
+
+        $this->buildDialog();
+        $this->dialog->updatedObject($this->information, false);
+        $this->dialog->requestData($_POST['parameters']['data'] ?? []);
+        $this->dialog->readValues();
+        $this->dialog->validate();
+    }
+
+    /**
+     * Updates a piece of information on a person after submitting the edit dialog.
+     *
+     * @return  string[]
+     */
+    public function submitEditDialog(): array
+    {
+        // If there are any validation errors, show the form again.
+        if ($this->dialog->hasValidationErrors()) {
+            return [
+                'dialog' => $this->dialog->getHtml(),
+                'formId' => $this->dialog->getId(),
+            ];
+        }
+
+        (new static([$this->information], 'update', $this->dialog->getData()))->executeAction();
+
+        // Reload the information with the updated data.
+        $information = new PersonInformation($this->information->getObjectID());
+
+        return [
+            'formattedInformation' => $information->getFormattedInformation(),
+            'informationID' => $this->information->getObjectID(),
+        ];
+    }
+
+    /**
+     * Builds the dialog to create or edit person information.
+     */
+    protected function buildDialog(): void
+    {
+        if ($this->dialog !== null) {
+            return;
+        }
+
+        $this->dialog = DialogFormDocument::create('personInformationAddDialog')
+            ->appendChild(
+                WysiwygFormContainer::create('information')
+                    ->messageObjectType('com.woltlab.wcf.people.information')
+                    ->required()
+            );
+
+        EventHandler::getInstance()->fireAction($this, 'buildDialog');
+
+        $this->dialog->build();
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationEditor.class.php
new file mode 100644 (file)
index 0000000..a5dffa7
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObjectEditor;
+
+/**
+ * Provides functions to edit person information.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\Person\Informtion
+ *
+ * @method static   PersonInformation   create(array $parameters = [])
+ * @method          PersonInformation   getDecoratedObject()
+ * @mixin           PersonInformation
+ */
+class PersonInformationEditor extends DatabaseObjectEditor
+{
+    /**
+     * @inheritDoc
+     */
+    protected static $baseClass = PersonInformation::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/data/person/information/PersonInformationList.class.php
new file mode 100644 (file)
index 0000000..5e4e89e
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\data\person\information;
+
+use wcf\data\DatabaseObjectList;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\cache\runtime\UserProfileRuntimeCache;
+
+/**
+ * Represents a list of person information.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Data\PersonInformation
+ *
+ * @method      PersonInformation       current()
+ * @method      PersonInformation[]     getObjects()
+ * @method      PersonInformation|null  search($objectID)
+ * @property    PersonInformation[]     $objects
+ */
+class PersonInformationList extends DatabaseObjectList
+{
+    public function readObjects()
+    {
+        parent::readObjects();
+
+        UserProfileRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_filter(\array_column(
+            $this->objects,
+            'userID'
+        ))));
+        PersonRuntimeCache::getInstance()->cacheObjectIDs(\array_unique(\array_column(
+            $this->objects,
+            'personID'
+        )));
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonListPage.class.php
new file mode 100644 (file)
index 0000000..5fbacd4
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace wcf\page;
+
+use wcf\data\person\PersonList;
+
+/**
+ * Shows the list of people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Page
+ */
+class PersonListPage extends SortablePage
+{
+    /**
+     * @inheritDoc
+     */
+    public $defaultSortField = 'lastName';
+
+    /**
+     * @inheritDoc
+     */
+    public $objectListClassName = PersonList::class;
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = ['personID', 'firstName', 'lastName'];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/page/PersonPage.class.php
new file mode 100644 (file)
index 0000000..24be70a
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+
+namespace wcf\page;
+
+use wcf\data\person\Person;
+use wcf\system\comment\CommentHandler;
+use wcf\system\comment\manager\PersonCommentManager;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\WCF;
+
+/**
+ * Shows the details of a certain person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Page
+ */
+class PersonPage extends AbstractPage
+{
+    /**
+     * list of comments
+     * @var StructuredCommentList
+     */
+    public $commentList;
+
+    /**
+     * person comment manager object
+     * @var PersonCommentManager
+     */
+    public $commentManager;
+
+    /**
+     * id of the person comment object type
+     * @var integer
+     */
+    public $commentObjectTypeID = 0;
+
+    /**
+     * shown person
+     * @var Person
+     */
+    public $person;
+
+    /**
+     * id of the shown person
+     * @var integer
+     */
+    public $personID = 0;
+
+    /**
+     * @inheritDoc
+     */
+    public function assignVariables()
+    {
+        parent::assignVariables();
+
+        WCF::getTPL()->assign([
+            'commentCanAdd' => WCF::getSession()->getPermission('user.person.canAddComment'),
+            'commentList' => $this->commentList,
+            'commentObjectTypeID' => $this->commentObjectTypeID,
+            'lastCommentTime' => $this->commentList ? $this->commentList->getMinCommentTime() : 0,
+            'likeData' => MODULE_LIKE && $this->commentList ? $this->commentList->getLikeData() : [],
+            'person' => $this->person,
+        ]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function readData()
+    {
+        parent::readData();
+
+        if ($this->person->enableComments) {
+            $this->commentObjectTypeID = CommentHandler::getInstance()->getObjectTypeID(
+                'com.woltlab.wcf.person.personComment'
+            );
+            $this->commentManager = CommentHandler::getInstance()->getObjectType(
+                $this->commentObjectTypeID
+            )->getProcessor();
+            $this->commentList = CommentHandler::getInstance()->getCommentList(
+                $this->commentManager,
+                $this->commentObjectTypeID,
+                $this->person->personID
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function readParameters()
+    {
+        parent::readParameters();
+
+        if (isset($_REQUEST['id'])) {
+            $this->personID = \intval($_REQUEST['id']);
+        }
+        $this->person = new Person($this->personID);
+        if (!$this->person->personID) {
+            throw new IllegalLinkException();
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/box/PersonListBoxController.class.php
new file mode 100644 (file)
index 0000000..95dc305
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace wcf\system\box;
+
+use wcf\data\person\PersonList;
+use wcf\system\WCF;
+
+/**
+ * Dynamic box controller implementation for a list of persons.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Box
+ */
+class PersonListBoxController extends AbstractDatabaseObjectListBoxController
+{
+    /**
+     * @inheritDoc
+     */
+    protected $conditionDefinition = 'com.woltlab.wcf.box.personList.condition';
+
+    /**
+     * @inheritDoc
+     */
+    public $defaultLimit = 5;
+
+    /**
+     * @inheritDoc
+     */
+    protected $sortFieldLanguageItemPrefix = 'wcf.person';
+
+    /**
+     * @inheritDoc
+     */
+    protected static $supportedPositions = [
+        'sidebarLeft',
+        'sidebarRight',
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public $validSortFields = [
+        'firstName',
+        'lastName',
+        'comments',
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public function getObjectList()
+    {
+        return new PersonList();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function getTemplate()
+    {
+        return WCF::getTPL()->fetch('boxPersonList', 'wcf', [
+            'boxPersonList' => $this->objectList,
+            'boxSortField' => $this->sortField,
+            'boxPosition' => $this->box->position,
+        ], true);
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/cache/runtime/PersonRuntimeCache.class.php
new file mode 100644 (file)
index 0000000..1f9a67f
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace wcf\system\cache\runtime;
+
+use wcf\data\person\Person;
+use wcf\data\person\PersonList;
+
+/**
+ * Runtime cache implementation for people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Cache\Runtime
+ *
+ * @method  Person[]    getCachedObjects()
+ * @method  Person      getObject($objectID)
+ * @method  Person[]    getObjects(array $objectIDs)
+ */
+class PersonRuntimeCache extends AbstractRuntimeCache
+{
+    /**
+     * @inheritDoc
+     */
+    protected $listClassName = PersonList::class;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/comment/manager/PersonCommentManager.class.php
new file mode 100644 (file)
index 0000000..6cbad9d
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace wcf\system\comment\manager;
+
+use wcf\data\person\Person;
+use wcf\data\person\PersonEditor;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\WCF;
+
+/**
+ * Comment manager implementation for people.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Comment\Manager
+ */
+class PersonCommentManager extends AbstractCommentManager
+{
+    /**
+     * @inheritDoc
+     */
+    protected $permissionAdd = 'user.person.canAddComment';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionAddWithoutModeration = 'user.person.canAddCommentWithoutModeration';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionCanModerate = 'mod.person.canModerateComment';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionDelete = 'user.person.canDeleteComment';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionEdit = 'user.person.canEditComment';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionModDelete = 'mod.person.canDeleteComment';
+
+    /**
+     * @inheritDoc
+     */
+    protected $permissionModEdit = 'mod.person.canEditComment';
+
+    /**
+     * @inheritDoc
+     */
+    public function getLink($objectTypeID, $objectID)
+    {
+        return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function isAccessible($objectID, $validateWritePermission = false)
+    {
+        return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getTitle($objectTypeID, $objectID, $isResponse = false)
+    {
+        if ($isResponse) {
+            return WCF::getLanguage()->get('wcf.person.commentResponse');
+        }
+
+        return WCF::getLanguage()->getDynamicVariable('wcf.person.comment');
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function updateCounter($objectID, $value)
+    {
+        (new PersonEditor(new Person($objectID)))->updateCounters(['comments' => $value]);
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonFirstNameTextPropertyCondition.class.php
new file mode 100644 (file)
index 0000000..38ae076
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace wcf\system\condition\person;
+
+use wcf\data\person\Person;
+use wcf\system\condition\AbstractObjectTextPropertyCondition;
+
+/**
+ * Condition implementation for the first name of a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license WoltLab License <http://www.woltlab.com/license-agreement.html>
+ * @package WoltLabSuite\Core\System\Condition
+ */
+class PersonFirstNameTextPropertyCondition extends AbstractObjectTextPropertyCondition
+{
+    /**
+     * @inheritDoc
+     */
+    protected $className = Person::class;
+
+    /**
+     * @inheritDoc
+     */
+    protected $description = 'wcf.person.condition.firstName.description';
+
+    /**
+     * @inheritDoc
+     */
+    protected $fieldName = 'personFirstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $label = 'wcf.person.firstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $propertyName = 'firstName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $supportsMultipleValues = true;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/condition/person/PersonLastNameTextPropertyCondition.class.php
new file mode 100644 (file)
index 0000000..8c06314
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace wcf\system\condition\person;
+
+use wcf\data\person\Person;
+use wcf\system\condition\AbstractObjectTextPropertyCondition;
+
+/**
+ * Condition implementation for the last name of a person.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license WoltLab License <http://www.woltlab.com/license-agreement.html>
+ * @package WoltLabSuite\Core\System\Condition
+ */
+class PersonLastNameTextPropertyCondition extends AbstractObjectTextPropertyCondition
+{
+    /**
+     * @inheritDoc
+     */
+    protected $className = Person::class;
+
+    /**
+     * @inheritDoc
+     */
+    protected $description = 'wcf.person.condition.lastName.description';
+
+    /**
+     * @inheritDoc
+     */
+    protected $fieldName = 'personLastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $label = 'wcf.person.lastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $propertyName = 'lastName';
+
+    /**
+     * @inheritDoc
+     */
+    protected $supportsMultipleValues = true;
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonPruneIpAddressesCronjobListener.class.php
new file mode 100644 (file)
index 0000000..a854da0
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+use wcf\system\cronjob\PruneIpAddressesCronjob;
+
+/**
+ * Prunes old ip addresses.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Event\Listener
+ */
+class PersonPruneIpAddressesCronjobListener extends AbstractEventListener
+{
+    protected function onExecute(PruneIpAddressesCronjob $cronjob): void
+    {
+        $cronjob->columns['wcf' . WCF_N . '_person_information']['ipAddress'] = 'time';
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserActionRenameListener.class.php
new file mode 100644 (file)
index 0000000..e18384e
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+/**
+ * Updates person information during user renaming.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Event\Listener
+ */
+class PersonUserActionRenameListener extends AbstractUserActionRenameListener
+{
+    /**
+     * @inheritDoc
+     */
+    protected $databaseTables = [
+        'wcf{WCF_N}_person_information',
+    ];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserExportGdprListener.class.php
new file mode 100644 (file)
index 0000000..6d76add
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+use wcf\acp\action\UserExportGdprAction;
+
+/**
+ * Adds the ip addresses stored with the person information during user data export.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Event\Listener
+ */
+class PersonUserExportGdprListener extends AbstractEventListener
+{
+    protected function onExport(UserExportGdprAction $action): void
+    {
+        $action->ipAddresses['com.woltlab.wcf.people'] = ['wcf' . WCF_N . '_person_information'];
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/event/listener/PersonUserMergeListener.class.php
new file mode 100644 (file)
index 0000000..f66467e
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\event\listener;
+
+/**
+ * Updates person information during user merging.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Event\Listener
+ */
+class PersonUserMergeListener extends AbstractUserMergeListener
+{
+    /**
+     * @inheritDoc
+     */
+    protected $databaseTables = [
+        'wcf{WCF_N}_person_information',
+    ];
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php b/snippets/tutorial/tutorial-series/part-5/files/lib/system/page/handler/PersonPageHandler.class.php
new file mode 100644 (file)
index 0000000..353900b
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+namespace wcf\system\page\handler;
+
+use wcf\data\page\Page;
+use wcf\data\person\PersonList;
+use wcf\data\user\online\UserOnline;
+use wcf\system\cache\runtime\PersonRuntimeCache;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\WCF;
+
+/**
+ * Page handler implementation for person page.
+ *
+ * @author  Matthias Schmidt
+ * @copyright   2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Page\Handler
+ */
+class PersonPageHandler extends AbstractLookupPageHandler implements IOnlineLocationPageHandler
+{
+    use TOnlineLocationPageHandler;
+
+    /**
+     * @inheritDoc
+     */
+    public function getLink($objectID)
+    {
+        return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();
+    }
+
+    /**
+     * Returns the textual description if a user is currently online viewing this page.
+     *
+     * @see IOnlineLocationPageHandler::getOnlineLocation()
+     *
+     * @param   Page        $page       visited page
+     * @param   UserOnline  $user       user online object with request data
+     * @return  string
+     */
+    public function getOnlineLocation(Page $page, UserOnline $user)
+    {
+        if ($user->pageObjectID === null) {
+            return '';
+        }
+
+        $person = PersonRuntimeCache::getInstance()->getObject($user->pageObjectID);
+        if ($person === null) {
+            return '';
+        }
+
+        return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.' . $page->identifier, ['person' => $person]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function isValid($objectID = null)
+    {
+        return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function lookup($searchString)
+    {
+        $conditionBuilder = new PreparedStatementConditionBuilder(false, 'OR');
+        $conditionBuilder->add('person.firstName LIKE ?', ['%' . $searchString . '%']);
+        $conditionBuilder->add('person.lastName LIKE ?', ['%' . $searchString . '%']);
+
+        $personList = new PersonList();
+        $personList->getConditionBuilder()->add($conditionBuilder, $conditionBuilder->getParameters());
+        $personList->readObjects();
+
+        $results = [];
+        foreach ($personList as $person) {
+            $results[] = [
+                'image' => 'fa-user',
+                'link' => $person->getLink(),
+                'objectID' => $person->personID,
+                'title' => $person->getTitle(),
+            ];
+        }
+
+        return $results;
+    }
+
+    /**
+     * Prepares fetching all necessary data for the textual description if a user is currently online
+     * viewing this page.
+     *
+     * @see IOnlineLocationPageHandler::prepareOnlineLocation()
+     *
+     * @param   Page        $page       visited page
+     * @param   UserOnline  $user       user online object with request data
+     */
+    public function prepareOnlineLocation(Page $page, UserOnline $user)
+    {
+        if ($user->pageObjectID !== null) {
+            PersonRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID);
+        }
+    }
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/language/de.xml b/snippets/tutorial/tutorial-series/part-5/language/de.xml
new file mode 100644 (file)
index 0000000..60e2523
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<language xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd" languagecode="de">
+       <category name="wcf.acp.box">
+               <item name="wcf.acp.box.boxController.com.woltlab.wcf.personList"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.acp.group">
+               <item name="wcf.acp.group.option.admin.content.canManagePeople"><![CDATA[Kann Personen verwalten]]></item>
+               <item name="wcf.acp.group.option.category.mod.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.group.option.category.user.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteComment"><![CDATA[Kann Kommentare löschen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteInformation"><![CDATA[Kann Informationen löschen]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditComment"><![CDATA[Kann Kommentare bearbeiten]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditInformation"><![CDATA[Kann Informationen bearbeiten]]></item>
+               <item name="wcf.acp.group.option.mod.person.canModerateComment"><![CDATA[Kann Kommentare moderieren]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddComment"><![CDATA[Kann Kommentare erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddCommentWithoutModeration"><![CDATA[Kann Kommentare ohne Moderation erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddInformation"><![CDATA[Kann Informationen erstellen]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteComment"><![CDATA[Kann eigene Kommentare löschen]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteInformation"><![CDATA[Kann eigene Informationen löschen]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Kann eigene Kommentare bearbeiten]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditInformation"><![CDATA[Kann eigene Informationen bearbeiten]]></item>
+       </category>
+       
+       <category name="wcf.acp.menu">
+               <item name="wcf.acp.menu.link.person"><![CDATA[Personen]]></item>
+               <item name="wcf.acp.menu.link.person.add"><![CDATA[Person hinzufügen]]></item>
+               <item name="wcf.acp.menu.link.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.acp.person">
+               <item name="wcf.acp.person.add"><![CDATA[Person hinzufügen]]></item>
+               <item name="wcf.acp.person.edit"><![CDATA[Person bearbeiten]]></item>
+               <item name="wcf.acp.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.page">
+               <item name="wcf.page.onlineLocation.com.woltlab.wcf.people.Person"><![CDATA[Person {anchor object=$person}]]></item>
+       </category>
+       
+       <category name="wcf.person">
+               <item name="wcf.person.boxList.description.comments"><![CDATA[{plural value=$boxPerson->comments 1='1 Kommentar' other='# Kommentare'}]]></item>
+               <item name="wcf.person.comment"><![CDATA[Person-Kommentar]]></item>
+               <item name="wcf.person.commentResponse"><![CDATA[Antwort auf Person-Kommentar]]></item>
+               <item name="wcf.person.comments"><![CDATA[Kommentare]]></item>
+               <item name="wcf.person.condition.firstName.description"><![CDATA[Mehrere Vornamen müssen durch ein Komma getrennt werden.]]></item>
+               <item name="wcf.person.condition.lastName.description"><![CDATA[Mehrere Nachnamen müssen durch ein Komma getrennt werden.]]></item>
+               <item name="wcf.person.enableComments"><![CDATA[Kommentare aktivieren]]></item>
+               <item name="wcf.person.enableComments.description"><![CDATA[Erlaubt es Benutzern diese Person zu kommentieren.]]></item>
+               <item name="wcf.person.firstName"><![CDATA[Vorname]]></item>
+               <item name="wcf.person.informationCount"><![CDATA[Informationen]]></item>
+               <item name="wcf.person.lastName"><![CDATA[Nachname]]></item>
+               <item name="wcf.person.list"><![CDATA[Personen]]></item>
+       </category>
+       
+       <category name="wcf.person.information">
+               <item name="wcf.person.information.add"><![CDATA[Information hinzufügen]]></item>
+               <item name="wcf.person.information.add.success"><![CDATA[Die Information wurde erfolgreich hinzugefügt.]]></item>
+               <item name="wcf.person.information.delete.confirmMessage"><![CDATA[{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} diese Information wirklich löschen?]]></item>
+               <item name="wcf.person.information.edit"><![CDATA[Information bearbeiten]]></item>
+               <item name="wcf.person.information.edit.success"><![CDATA[Die Information wurde erfolgreich bearbeitet.]]></item>
+               <item name="wcf.person.information.list"><![CDATA[Informationen]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-5/language/en.xml b/snippets/tutorial/tutorial-series/part-5/language/en.xml
new file mode 100644 (file)
index 0000000..ff19b81
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<language xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd" languagecode="en">
+       <category name="wcf.acp.box">
+               <item name="wcf.acp.box.boxController.com.woltlab.wcf.personList"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.acp.group">
+               <item name="wcf.acp.group.option.admin.content.canManagePeople"><![CDATA[Can manage people]]></item>
+               <item name="wcf.acp.group.option.category.mod.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.group.option.category.user.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteComment"><![CDATA[Can delete comments]]></item>
+               <item name="wcf.acp.group.option.mod.person.canDeleteInformation"><![CDATA[Can delete pieces of information]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditComment"><![CDATA[Can edit comments]]></item>
+               <item name="wcf.acp.group.option.mod.person.canEditInformation"><![CDATA[Can edit pieces of information]]></item>
+               <item name="wcf.acp.group.option.mod.person.canModerateComment"><![CDATA[Can moderate comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddComment"><![CDATA[Can create comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddCommentWithoutModeration"><![CDATA[Can create comments without approval]]></item>
+               <item name="wcf.acp.group.option.user.person.canAddInformation"><![CDATA[Can create information]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteComment"><![CDATA[Can delete their comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canDeleteInformation"><![CDATA[Can delete their pieces of information]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditComment"><![CDATA[Can edit their comments]]></item>
+               <item name="wcf.acp.group.option.user.person.canEditInformation"><![CDATA[Can edit their pieces of information]]></item>
+       </category>
+       
+       <category name="wcf.acp.menu">
+               <item name="wcf.acp.menu.link.person"><![CDATA[People]]></item>
+               <item name="wcf.acp.menu.link.person.add"><![CDATA[Add Person]]></item>
+               <item name="wcf.acp.menu.link.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.acp.person">
+               <item name="wcf.acp.person.add"><![CDATA[Add Person]]></item>
+               <item name="wcf.acp.person.edit"><![CDATA[Edit Person]]></item>
+               <item name="wcf.acp.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.page">
+               <item name="wcf.page.onlineLocation.com.woltlab.wcf.people.Person"><![CDATA[Person {anchor object=$person}]]></item>
+       </category>
+       
+       <category name="wcf.person">
+               <item name="wcf.person.boxList.description.comments"><![CDATA[{plural value=$boxPerson->comments 1='1 Comment' other='# Comments'}]]></item>
+               <item name="wcf.person.comment"><![CDATA[Person Comment]]></item>
+               <item name="wcf.person.commentResponse"><![CDATA[Reply to Person Comment]]></item>
+               <item name="wcf.person.comments"><![CDATA[Comments]]></item>
+               <item name="wcf.person.condition.firstName.description"><![CDATA[Multiple first names have to be separated by commas.]]></item>
+               <item name="wcf.person.condition.lastName.description"><![CDATA[Multiple last names have to be separated by commas.]]></item>
+               <item name="wcf.person.enableComments"><![CDATA[Allow Comments]]></item>
+               <item name="wcf.person.enableComments.description"><![CDATA[Allow users to comment on this person.]]></item>
+               <item name="wcf.person.firstName"><![CDATA[First Name]]></item>
+               <item name="wcf.person.informationCount"><![CDATA[Pieces of Information]]></item>
+               <item name="wcf.person.lastName"><![CDATA[Last Name]]></item>
+               <item name="wcf.person.list"><![CDATA[People]]></item>
+       </category>
+       
+       <category name="wcf.person.information">
+               <item name="wcf.person.information.add"><![CDATA[Add Information]]></item>
+               <item name="wcf.person.information.add.success"><![CDATA[The piece of information has been added successfully.]]></item>
+               <item name="wcf.person.information.delete.confirmMessage"><![CDATA[Do you really want to delete this piece of information?]]></item>
+               <item name="wcf.person.information.edit"><![CDATA[Edit Information]]></item>
+               <item name="wcf.person.information.edit.success"><![CDATA[The piece of information has been edited successfully.]]></item>
+               <item name="wcf.person.information.list"><![CDATA[Information]]></item>
+       </category>
+</language>
diff --git a/snippets/tutorial/tutorial-series/part-5/menuItem.xml b/snippets/tutorial/tutorial-series/part-5/menuItem.xml
new file mode 100644 (file)
index 0000000..bcf3e04
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/menuItem.xsd">
+       <import>
+               <item identifier="com.woltlab.wcf.people.PersonList">
+                       <menu>com.woltlab.wcf.MainMenu</menu>
+                       <title language="de">Personen</title>
+                       <title language="en">People</title>
+                       <page>com.woltlab.wcf.people.PersonList</page>
+               </item>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/objectType.xml b/snippets/tutorial/tutorial-series/part-5/objectType.xml
new file mode 100644 (file)
index 0000000..120c761
--- /dev/null
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectType.xsd">
+       <import>
+               <type>
+                       <name>com.woltlab.wcf.person.personComment</name>
+                       <definitionname>com.woltlab.wcf.comment.commentableContent</definitionname>
+                       <classname>wcf\system\comment\manager\PersonCommentManager</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.personList</name>
+                       <definitionname>com.woltlab.wcf.boxController</definitionname>
+                       <classname>wcf\system\box\PersonListBoxController</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.firstName</name>
+                       <definitionname>com.woltlab.wcf.box.personList.condition</definitionname>
+                       <classname>wcf\system\condition\person\PersonFirstNameTextPropertyCondition</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.lastName</name>
+                       <definitionname>com.woltlab.wcf.box.personList.condition</definitionname>
+                       <classname>wcf\system\condition\person\PersonLastNameTextPropertyCondition</classname>
+               </type>
+               <type>
+                       <name>com.woltlab.wcf.people.information</name>
+                       <definitionname>com.woltlab.wcf.message</definitionname>
+               </type>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml b/snippets/tutorial/tutorial-series/part-5/objectTypeDefinition.xml
new file mode 100644 (file)
index 0000000..82414de
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectTypeDefinition.xsd">
+       <import>
+               <definition>
+                       <name>com.woltlab.wcf.box.personList.condition</name>
+                       <interfacename>wcf\system\condition\IObjectListCondition</interfacename>
+               </definition>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/package.xml b/snippets/tutorial/tutorial-series/part-5/package.xml
new file mode 100644 (file)
index 0000000..f7df729
--- /dev/null
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package name="com.woltlab.wcf.people" xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/package.xsd">
+       <packageinformation>
+               <packagename>WoltLab Suite Core Tutorial: People</packagename>
+               <packagedescription>Adds a simple management system for people as part of a tutorial to create packages.</packagedescription>
+               <version>5.4.0</version>
+               <date>2021-04-16</date>
+       </packageinformation>
+       
+       <authorinformation>
+               <author>WoltLab GmbH</author>
+               <authorurl>http://www.woltlab.com</authorurl>
+       </authorinformation>
+       
+       <requiredpackages>
+               <requiredpackage minversion="5.4.0 Alpha 1">com.woltlab.wcf</requiredpackage>
+       </requiredpackages>
+       
+       <excludedpackages>
+               <excludedpackage version="6.0.0 Alpha 1">com.woltlab.wcf</excludedpackage>
+       </excludedpackages>
+       
+       <instructions type="install">
+               <instruction type="acpTemplate" />
+               <instruction type="file" />
+               <instruction type="database">acp/database/install_com.woltlab.wcf.people.php</instruction>
+               <instruction type="template" />
+               <instruction type="language" />
+               
+               <instruction type="acpMenu" />
+               <instruction type="eventListener" />
+               <instruction type="page" />
+               <instruction type="menuItem" />
+               <instruction type="userGroupOption" />
+       </instructions>
+</package>
diff --git a/snippets/tutorial/tutorial-series/part-5/page.xml b/snippets/tutorial/tutorial-series/part-5/page.xml
new file mode 100644 (file)
index 0000000..8270c57
--- /dev/null
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/page.xsd">
+       <import>
+               <page identifier="com.woltlab.wcf.people.PersonList">
+                       <pageType>system</pageType>
+                       <controller>wcf\page\PersonListPage</controller>
+                       <name language="de">Personen-Liste</name>
+                       <name language="en">Person List</name>
+                       
+                       <content language="de">
+                               <title>Personen</title>
+                       </content>
+                       <content language="en">
+                               <title>People</title>
+                       </content>
+               </page>
+               <page identifier="com.woltlab.wcf.people.Person">
+                       <pageType>system</pageType>
+                       <controller>wcf\page\PersonPage</controller>
+                       <handler>wcf\system\page\handler\PersonPageHandler</handler>
+                       <name language="de">Person</name>
+                       <name language="en">Person</name>
+                       <requireObjectID>1</requireObjectID>
+                       <parent>com.woltlab.wcf.people.PersonList</parent>
+               </page>
+       </import>
+</data>
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl b/snippets/tutorial/tutorial-series/part-5/templates/boxPersonList.tpl
new file mode 100644 (file)
index 0000000..146ce16
--- /dev/null
@@ -0,0 +1,15 @@
+<ul class="sidebarItemList">
+    {foreach from=$boxPersonList item=boxPerson}
+        <li class="box24">
+            <span class="icon icon24 fa-user"></span>
+
+            <div class="sidebarItemTitle">
+                <h3>{anchor object=$boxPerson}</h3>
+                {capture assign='__boxPersonDescription'}{lang __optional=true}wcf.person.boxList.description.{$boxSortField}{/lang}{/capture}
+                {if $__boxPersonDescription}
+                    <small>{@$__boxPersonDescription}</small>
+                {/if}
+            </div>
+        </li>
+    {/foreach}
+</ul>
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/person.tpl b/snippets/tutorial/tutorial-series/part-5/templates/person.tpl
new file mode 100644 (file)
index 0000000..863cdf2
--- /dev/null
@@ -0,0 +1,141 @@
+{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}
+
+{capture assign='contentTitle'}{$person}{/capture}
+
+{include file='header'}
+
+{if $person->informationCount || $__wcf->session->getPermission('user.person.canAddInformation')}
+       <section class="section sectionContainerList">
+               <header class="sectionHeader">
+                       <h2 class="sectionTitle">
+                               {lang}wcf.person.information.list{/lang}
+                               {if $person->informationCount}
+                                       <span class="badge">{#$person->informationCount}</span>
+                               {/if}
+                       </h2>
+               </header>
+               
+               <ul class="commentList containerList personInformationList jsObjectActionContainer" {*
+                       *}data-object-action-class-name="wcf\data\person\information\PersonInformationAction"{*
+               *}>
+                       {if $__wcf->session->getPermission('user.person.canAddInformation')}
+                               <li class="containerListButtonGroup">
+                                       <ul class="buttonGroup">
+                                               <li>
+                                                       <a href="#" class="button" id="personInformationAddButton">
+                                                               <span class="icon icon16 fa-plus"></span>
+                                                               <span>{lang}wcf.person.information.add{/lang}</span>
+                                                       </a>
+                                               </li>
+                                       </ul>
+                               </li>
+                       {/if}
+                       
+                       {foreach from=$person->getInformation() item=$information}
+                               <li class="comment personInformation jsObjectActionObject" data-object-id="{@$information->getObjectID()}">
+                                       <div class="box48{if $__wcf->getUserProfileHandler()->isIgnoredUser($information->userID)} ignoredUserContent{/if}">
+                                               {user object=$information->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}
+                                               
+                                               <div class="commentContentContainer">
+                                                       <div class="commentContent">
+                                                               <div class="containerHeadline">
+                                                                       <h3>
+                                                                               {if $information->userID}
+                                                                                       {user object=$information->getUserProfile()}
+                                                                               {else}
+                                                                                       <span>{$information->username}</span>
+                                                                               {/if}
+                                                                               
+                                                                               <small class="separatorLeft">{@$information->time|time}</small>
+                                                                       </h3>
+                                                               </div>
+                                                               
+                                                               <div class="htmlContent userMessage" id="personInformation{@$information->getObjectID()}">
+                                                                       {@$information->getFormattedInformation()}
+                                                               </div>
+                                                               
+                                                               <nav class="jsMobileNavigation buttonGroupNavigation">
+                                                                       <ul class="buttonList iconList">
+                                                                               {if $information->canEdit()}
+                                                                                       <li class="jsOnly">
+                                                                                               <a href="#" title="{lang}wcf.global.button.edit{/lang}" class="jsEditInformation jsTooltip">
+                                                                                                       <span class="icon icon16 fa-pencil"></span>
+                                                                                                       <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
+                                                                                               </a>
+                                                                                       </li>
+                                                                               {/if}
+                                                                               {if $information->canDelete()}
+                                                                                       <li class="jsOnly">
+                                                                                               <a href="#" title="{lang}wcf.global.button.delete{/lang}" class="jsObjectAction jsTooltip" data-object-action="delete" data-confirm-message="{lang}wcf.person.information.delete.confirmMessage{/lang}">
+                                                                                                       <span class="icon icon16 fa-times"></span>
+                                                                                                       <span class="invisible">{lang}wcf.global.button.edit{/lang}</span>
+                                                                                               </a>
+                                                                                       </li>
+                                                                               {/if}
+                                                                               
+                                                                               {event name='informationOptions'}
+                                                                       </ul>
+                                                               </nav>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </li>
+                       {/foreach}
+               </ul>
+       </section>
+{/if}
+
+{if $person->enableComments}
+       {if $commentList|count || $commentCanAdd}
+               <section id="comments" class="section sectionContainerList">
+                       <header class="sectionHeader">
+                               <h2 class="sectionTitle">
+                                       {lang}wcf.person.comments{/lang}
+                                       {if $person->comments}<span class="badge">{#$person->comments}</span>{/if}
+                               </h2>
+                       </header>
+                       
+                       {include file='__commentJavaScript' commentContainerID='personCommentList'}
+                       
+                       <div class="personComments">
+                               <ul id="personCommentList" class="commentList containerList" {*
+                                       *}data-can-add="{if $commentCanAdd}true{else}false{/if}" {*
+                                       *}data-object-id="{@$person->personID}" {*
+                                       *}data-object-type-id="{@$commentObjectTypeID}" {*
+                                       *}data-comments="{if $person->comments}{@$commentList->countObjects()}{else}0{/if}" {*
+                                       *}data-last-comment-time="{@$lastCommentTime}" {*
+                               *}>
+                                       {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}
+                                       {include file='commentList'}
+                               </ul>
+                       </div>
+               </section>
+       {/if}
+{/if}
+
+<footer class="contentFooter">
+       {hascontent}
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               {content}{event name='contentFooterNavigation'}{/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</footer>
+
+<script data-relocate="true">
+       require(['Language', 'WoltLabSuite/Core/Controller/Person'], (Language, ControllerPerson) => {
+               Language.addObject({
+                       'wcf.person.information.add': '{jslang}wcf.person.information.add{/jslang}',
+                       'wcf.person.information.add.success': '{jslang}wcf.person.information.add.success{/jslang}',
+                       'wcf.person.information.edit': '{jslang}wcf.person.information.edit{/jslang}',
+                       'wcf.person.information.edit.success': '{jslang}wcf.person.information.edit.success{/jslang}',
+               });
+               
+               ControllerPerson.init({@$person->personID}, {
+                       canAddInformation: {if $__wcf->session->getPermission('user.person.canAddInformation')}true{else}false{/if},
+               });
+       });
+</script>
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/templates/personList.tpl b/snippets/tutorial/tutorial-series/part-5/templates/personList.tpl
new file mode 100644 (file)
index 0000000..1f72ee9
--- /dev/null
@@ -0,0 +1,113 @@
+{capture assign='contentTitle'}{lang}wcf.person.list{/lang} <span class="badge">{#$items}</span>{/capture}
+
+{capture assign='headContent'}
+       {if $pageNo < $pages}
+               <link rel="next" href="{link controller='PersonList'}pageNo={@$pageNo+1}{/link}">
+       {/if}
+       {if $pageNo > 1}
+               <link rel="prev" href="{link controller='PersonList'}{if $pageNo > 2}pageNo={@$pageNo-1}{/if}{/link}">
+       {/if}
+       <link rel="canonical" href="{link controller='PersonList'}{if $pageNo > 1}pageNo={@$pageNo}{/if}{/link}">
+{/capture}
+
+{capture assign='sidebarRight'}
+       <section class="box">
+               <form method="post" action="{link controller='PersonList'}{/link}">
+                       <h2 class="boxTitle">{lang}wcf.global.sorting{/lang}</h2>
+                       
+                       <div class="boxContent">
+                               <dl>
+                                       <dt></dt>
+                                       <dd>
+                                               <select id="sortField" name="sortField">
+                                                       <option value="firstName"{if $sortField == 'firstName'} selected{/if}>{lang}wcf.person.firstName{/lang}</option>
+                                                       <option value="lastName"{if $sortField == 'lastName'} selected{/if}>{lang}wcf.person.lastName{/lang}</option>
+                                                       {event name='sortField'}
+                                               </select>
+                                               <select name="sortOrder">
+                                                       <option value="ASC"{if $sortOrder == 'ASC'} selected{/if}>{lang}wcf.global.sortOrder.ascending{/lang}</option>
+                                                       <option value="DESC"{if $sortOrder == 'DESC'} selected{/if}>{lang}wcf.global.sortOrder.descending{/lang}</option>
+                                               </select>
+                                       </dd>
+                               </dl>
+                               
+                               <div class="formSubmit">
+                                       <input type="submit" value="{lang}wcf.global.button.submit{/lang}" accesskey="s">
+                               </div>
+                       </div>
+               </form>
+       </section>
+{/capture}
+
+{include file='header'}
+
+{hascontent}
+       <div class="paginationTop">
+               {content}
+                       {pages print=true assign=pagesLinks controller='PersonList' link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder"}
+               {/content}
+       </div>
+{/hascontent}
+
+{if $items}
+       <div class="section sectionContainerList">
+               <ol class="containerList personList">
+                       {foreach from=$objects item=person}
+                               <li>
+                                       <div class="box48">
+                                               <span class="icon icon48 fa-user"></span>
+                                               
+                                               <div class="details personInformation">
+                                                       <div class="containerHeadline">
+                                                               <h3>{anchor object=$person}</h3>
+                                                       </div>
+                                                       
+                                                       {hascontent}
+                                                               <ul class="inlineList commaSeparated">
+                                                                       {content}{event name='personData'}{/content}
+                                                               </ul>
+                                                       {/hascontent}
+                                                       
+                                                       {hascontent}
+                                                               <dl class="plain inlineDataList small">
+                                                                       {content}
+                                                                               {if $person->informationCount}
+                                                                                       <dt>{lang}wcf.person.informationCount{/lang}</dt>
+                                                                                       <dd>{#$person->informationCount}</dd>
+                                                                               {/if}
+                                                                               {if $person->enableComments}
+                                                                                       <dt>{lang}wcf.person.comments{/lang}</dt>
+                                                                                       <dd>{#$person->comments}</dd>
+                                                                               {/if}
+                                                                               
+                                                                               {event name='personStatistics'}
+                                                                       {/content}
+                                                               </dl>
+                                                       {/hascontent}
+                                               </div>
+                                       </div>
+                               </li>
+                       {/foreach}
+               </ol>
+       </div>
+{else}
+       <p class="info">{lang}wcf.global.noItems{/lang}</p>
+{/if}
+
+<footer class="contentFooter">
+       {hascontent}
+               <div class="paginationBottom">
+                       {content}{@$pagesLinks}{/content}
+               </div>
+       {/hascontent}
+       
+       {hascontent}
+               <nav class="contentFooterNavigation">
+                       <ul>
+                               {content}{event name='contentFooterNavigation'}{/content}
+                       </ul>
+               </nav>
+       {/hascontent}
+</footer>
+
+{include file='footer'}
diff --git a/snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts b/snippets/tutorial/tutorial-series/part-5/ts/WoltLabSuite/Core/Controller/Person.ts
new file mode 100644 (file)
index 0000000..5a8e150
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * Provides the JavaScript code for the person page.
+ *
+ * @author  Matthias Schmidt
+ * @copyright  2001-2021 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Controller/Person
+ */
+
+import FormBuilderDialog from "WoltLabSuite/Core/Form/Builder/Dialog";
+import * as Language from "WoltLabSuite/Core/Language";
+import * as UiNotification from "WoltLabSuite/Core/Ui/Notification";
+
+let addDialog: FormBuilderDialog;
+const editDialogs = new Map<string, FormBuilderDialog>();
+
+interface EditReturnValues {
+  formattedInformation: string;
+  informationID: number;
+}
+
+interface Options {
+  canAddInformation: true;
+}
+
+/**
+ * Opens the edit dialog after clicking on the edit button for a piece of information.
+ */
+function editInformation(event: Event): void {
+  event.preventDefault();
+
+  const currentTarget = event.currentTarget as HTMLElement;
+  const information = currentTarget.closest(".jsObjectActionObject") as HTMLElement;
+  const informationId = information.dataset.objectId!;
+
+  if (!editDialogs.has(informationId)) {
+    editDialogs.set(
+      informationId,
+      new FormBuilderDialog(
+        `personInformationEditDialog${informationId}`,
+        "wcf\\data\\person\\information\\PersonInformationAction",
+        "getEditDialog",
+        {
+          actionParameters: {
+            informationID: informationId,
+          },
+          dialog: {
+            title: Language.get("wcf.person.information.edit"),
+          },
+          submitActionName: "submitEditDialog",
+          successCallback(returnValues: EditReturnValues) {
+            document.getElementById(`personInformation${returnValues.informationID}`)!.innerHTML =
+              returnValues.formattedInformation;
+
+            UiNotification.show(Language.get("wcf.person.information.edit.success"));
+          },
+        },
+      ),
+    );
+  }
+
+  editDialogs.get(informationId)!.open();
+}
+
+/**
+ * Initializes the JavaScript code for the person page.
+ */
+export function init(personId: number, options: Options): void {
+  if (options.canAddInformation) {
+    // Initialize the dialog to add new information.
+    addDialog = new FormBuilderDialog(
+      "personInformationAddDialog",
+      "wcf\\data\\person\\information\\PersonInformationAction",
+      "getAddDialog",
+      {
+        actionParameters: {
+          personID: personId,
+        },
+        dialog: {
+          title: Language.get("wcf.person.information.add"),
+        },
+        submitActionName: "submitAddDialog",
+        successCallback() {
+          UiNotification.show(Language.get("wcf.person.information.add.success"), () => window.location.reload());
+        },
+      },
+    );
+
+    document.getElementById("personInformationAddButton")!.addEventListener("click", (event) => {
+      event.preventDefault();
+
+      addDialog.open();
+    });
+  }
+
+  document
+    .querySelectorAll(".jsEditInformation")
+    .forEach((el) => el.addEventListener("click", (ev) => editInformation(ev)));
+}
diff --git a/snippets/tutorial/tutorial-series/part-5/userGroupOption.xml b/snippets/tutorial/tutorial-series/part-5/userGroupOption.xml
new file mode 100644 (file)
index 0000000..d5bc636
--- /dev/null
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/userGroupOption.xsd">
+       <import>
+               <categories>
+                       <category name="mod.person">
+                               <parent>mod</parent>
+                       </category>
+                       <category name="user.person">
+                               <parent>user</parent>
+                       </category>
+               </categories>
+               
+               <options>
+                       <option name="admin.content.canManagePeople">
+                               <categoryname>admin.content</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <admindefaultvalue>1</admindefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canModerateComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canEditComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canDeleteComment">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canEditInformation">
+                               <categoryname>mod.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="mod.person.canDeleteInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <moddefaultvalue>1</moddefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="user.person.canAddInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canEditInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canDeleteInformation">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canAddComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <enableoptions>user.person.canAddCommentWithoutModeration</enableoptions>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canAddCommentWithoutModeration">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <excludedInTinyBuild>1</excludedInTinyBuild>
+                       </option>
+                       <option name="user.person.canEditComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>1</defaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+                       <option name="user.person.canDeleteComment">
+                               <categoryname>user.person</categoryname>
+                               <optiontype>boolean</optiontype>
+                               <defaultvalue>0</defaultvalue>
+                               <admindefaultvalue>1</admindefaultvalue>
+                               <usersonly>1</usersonly>
+                       </option>
+               </options>
+       </import>
+</data>