diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..1cd5e40
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,38 @@
+name: Lint
+
+# PHP syntax check on the lowest PHP version this extension supports and the
+# newest one phpBB 3.3 supports. tests.yml runs the tests, code sniffer and
+# EPV on phpBB 3.3 and 4.0.
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ name: PHP ${{ matrix.php }} lint
+ runs-on: ubuntu-24.04
+ strategy:
+ matrix:
+ php: ['7.4', '8.4']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ tools: composer:v2
+
+ - name: PHP syntax check
+ run: find . -name '*.php' -not -path './.git/*' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
+
+ - name: Validate composer.json
+ run: composer validate --no-check-all --no-check-publish
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..28c15ce
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,45 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ # phpBB 3.3.x compatibility
+ call-tests-3-3-x:
+ name: Extension tests (phpBB 3.3.x)
+ uses: phpbb-extensions/test-framework/.github/workflows/tests.yml@3.3.x
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ # EPV runs in the epv job below instead
+ EPV: 0
+ PHPBB_BRANCH: '3.3.x'
+ RUN_FUNCTIONAL_TESTS: 0
+ PRIMARY_PHP_VERSION: '7.4'
+ PHP_VERSION_MATRIX: '["7.4", "8.0", "8.1", "8.2"]'
+
+ # phpBB 4.0 (master) compatibility
+ call-tests-master:
+ name: Extension tests (phpBB master / 4.0)
+ uses: phpbb-extensions/test-framework/.github/workflows/tests.yml@master
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ # EPV runs in the epv job below instead
+ EPV: 0
+ PHPBB_BRANCH: 'master'
+ RUN_FUNCTIONAL_TESTS: 0
+ PRIMARY_PHP_VERSION: '8.2'
+ PHP_VERSION_MATRIX: '["8.2", "8.3", "8.4"]'
+
+ # EPV (Extension Pre Validator), failing on any Fatal or Error finding not
+ # listed in .github/epv-allowed.txt
+ epv:
+ uses: phpbbmodders/.github/.github/workflows/epv.yml@v1
+ with:
+ EXTNAME: phpbbmodders/seenotes
+ PHPBB_BRANCHES: '["3.3.x", "master"]'
diff --git a/README.md b/README.md
index ae0cdee..65b21da 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,45 @@
+admin KbTest1234!
# See Notes
+[](https://github.com/phpbbmodders/seenotes/actions/workflows/tests.yml) [](https://github.com/phpbbmodders/seenotes/actions/workflows/lint.yml)
+
Lets moderators mark a member with a staff-only reason from a list the administrators set, and keep dated notes under each reason.
-> **Status: design stage.** There is no code yet. The planned behavior is in [`docs/design.md`](docs/design.md).
+## Features
+
+- **ACP → Extensions → See Notes → Reasons**: administrators set up the list of reasons, each with a description, a color, and an on/off switch. Seven starter reasons (Moderation queue, Spam watch, Warez, Conduct, Possible returning member, Staff in contact, General) are added on first enable, ready to edit or remove. A switched-off reason can't be given any more, but members who have it keep it and its notes.
+- **MCP → Moderator notes** and **ACP → Manage users → Moderator notes**: give a member a reason, choose which reason is current (or none), and add notes under each reason.
+- **Notes use BBCode**, smilies and links, following the board's private message settings, so a PM sent to the member can be pasted into a quote for other staff.
+- **Only staff see it.** The current reason shows as "SEE NOTES: Moderation queue (3)" on the member's profile, next to their posts, on the MCP post, report and approval queue pages, and in the MCP topic view. It links to the notes. A member with no current reason shows "SEE NOTES: NONE", which links straight to the form that gives one.
+- The member's latest **phpBB user notes** (warnings, bans and so on) show read-only on the same page.
+- Separate permissions for seeing, adding, editing notes, deleting notes and deleting a reason entry, with matching administrator permissions.
+- Every change goes into the moderator log. Deleting a reason with all its entries is off by default, and needs the reason's name typed to confirm.
+- A member's entries and notes are deleted with the member.
+
+The full design is in [`docs/design.md`](docs/design.md).
-## Planned features
+## Screenshots
-- Administrators set up the list of reasons in the ACP, and can switch each one on or off.
-- Moderators give a member one or more reason entries, each with its own notes, and choose which one is current.
-- The current reason shows in the member's profile and mini-profile, only to staff with permission.
-- Separate permissions for seeing, managing, editing notes, deleting notes and deleting entries.
-- Every change goes into the moderator log.
+
+
+Click an image for the full size. All screenshots, with captions: [docs/images/screenshots](docs/images/screenshots/README.md).
## Requirements
-- phpBB 3.3.19 or later, including phpBB 4.0
-- PHP 7.4 or later (phpBB 4.0 itself needs PHP 8.2 or later)
+- phpBB 3.3.19 or later, including phpBB 4.0 (tested on its 4.0.0-a3 development version)
+- PHP 8.3.36 or later
+
+## Installation
+
+1. Copy the extension to `/ext/phpbbmodders/seenotes`
+2. In the Administration Control Panel, go to **Customise → Manage extensions**
+3. Enable the **See Notes** extension
+4. Review the starter reasons under **Extensions → See Notes → Reasons**, and add, rename or switch off any you like
## Contributing
@@ -27,7 +51,8 @@ Contributions are welcome!
## Acknowledgments
-- Design written with [Claude](https://www.anthropic.com/claude).
+- Design reviewed by [kaileymsnay](https://github.com/kaileymsnay), who asked for BBCode in notes.
+- Code, tests and documentation written with [Claude](https://www.anthropic.com/claude).
## License
diff --git a/acp/main_info.php b/acp/main_info.php
new file mode 100644
index 0000000..9e81c0e
--- /dev/null
+++ b/acp/main_info.php
@@ -0,0 +1,37 @@
+ '\phpbbmodders\seenotes\acp\main_module',
+ 'title' => 'ACP_SEENOTES',
+ 'modes' => [
+ 'reasons' => [
+ 'title' => 'ACP_SEENOTES_REASONS',
+ 'auth' => 'ext_phpbbmodders/seenotes && acl_a_seenotes_settings',
+ 'cat' => ['ACP_SEENOTES'],
+ ],
+ 'settings' => [
+ 'title' => 'ACP_SEENOTES_SETTINGS',
+ 'auth' => 'ext_phpbbmodders/seenotes && acl_a_seenotes_settings',
+ 'cat' => ['ACP_SEENOTES'],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/acp/main_module.php b/acp/main_module.php
new file mode 100644
index 0000000..cf54100
--- /dev/null
+++ b/acp/main_module.php
@@ -0,0 +1,363 @@
+add_lang('acp_seenotes', 'phpbbmodders/seenotes');
+ $this->manager = $phpbb_container->get('phpbbmodders.seenotes.manager');
+
+ add_form_key(self::FORM_KEY);
+
+ if ($mode === 'settings')
+ {
+ $this->settings();
+ return;
+ }
+
+ $this->reasons();
+ }
+
+ /**
+ * The label and the settings
+ *
+ * @return void
+ */
+ protected function settings()
+ {
+ global $config, $language, $request, $template;
+
+ $this->tpl_name = '@phpbbmodders_seenotes/acp_seenotes_settings';
+ $this->page_title = 'ACP_SEENOTES_SETTINGS';
+
+ if ($request->is_set_post('submit'))
+ {
+ $this->check_form();
+
+ $label = trim($request->variable('seenotes_label', '', true));
+ if ($label === '' || utf8_strlen($label) > self::LABEL_MAX_LENGTH)
+ {
+ $this->error($language->lang('SEENOTES_LABEL_INVALID', self::LABEL_MAX_LENGTH));
+ }
+
+ $config->set('seenotes_label', $label);
+ $config->set('seenotes_require_description', (int) $request->variable('seenotes_require_description', false));
+ $config->set('seenotes_allow_hard_delete', (int) $request->variable('seenotes_allow_hard_delete', false));
+
+ $this->manager->log_admin('LOG_SEENOTES_SETTINGS');
+ trigger_error($language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action));
+ }
+
+ $template->assign_vars([
+ 'U_ACTION' => $this->u_action,
+ 'SEENOTES_LABEL' => $this->manager->get_label(),
+ 'SEENOTES_LABEL_MAX_LENGTH' => self::LABEL_MAX_LENGTH,
+ 'S_SEENOTES_REQUIRE_DESCRIPTION'=> !empty($config['seenotes_require_description']),
+ 'S_SEENOTES_ALLOW_HARD_DELETE' => !empty($config['seenotes_allow_hard_delete']),
+ ]);
+ }
+
+ /**
+ * The list of reasons, and the forms to add, change, move, switch and
+ * delete them
+ *
+ * @return void
+ */
+ protected function reasons()
+ {
+ global $config, $language, $request, $template;
+
+ $this->tpl_name = '@phpbbmodders_seenotes/acp_seenotes_reasons';
+ $this->page_title = 'ACP_SEENOTES_REASONS';
+
+ $action = $request->variable('action', '');
+ $reason_id = $request->variable('id', 0);
+
+ switch ($action)
+ {
+ case 'add':
+ case 'edit':
+ $this->edit_reason($action === 'edit' ? $reason_id : 0);
+ return;
+
+ case 'move_up':
+ case 'move_down':
+ case 'enable':
+ case 'disable':
+ $this->quick_action($action, $reason_id);
+ break;
+
+ case 'delete':
+ case 'delete_confirm':
+ case 'delete_final':
+ $this->delete_reason($action, $reason_id);
+ return;
+ }
+
+ $hash = generate_link_hash('seenotes_reasons');
+ foreach ($this->manager->get_reasons() as $id => $reason)
+ {
+ $template->assign_block_vars('reasons', [
+ 'TITLE' => $reason['reason_title'],
+ 'DESCRIPTION' => $reason['reason_description'],
+ 'COLOR' => $this->manager->clean_color($reason['reason_color']),
+ 'S_ACTIVE' => (bool) $reason['reason_active'],
+ 'U_EDIT' => $this->u_action . '&action=edit&id=' . $id,
+ 'U_MOVE_UP' => $this->u_action . '&action=move_up&id=' . $id . '&hash=' . $hash,
+ 'U_MOVE_DOWN' => $this->u_action . '&action=move_down&id=' . $id . '&hash=' . $hash,
+ 'U_TOGGLE' => $this->u_action . '&action=' . ($reason['reason_active'] ? 'disable' : 'enable') . '&id=' . $id . '&hash=' . $hash,
+ 'U_DELETE' => $this->u_action . '&action=delete&id=' . $id,
+ ]);
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_HARD_DELETE' => !empty($config['seenotes_allow_hard_delete']),
+ 'U_ACTION' => $this->u_action,
+ 'U_SEENOTES_ADD' => $this->u_action . '&action=add',
+ ]);
+ }
+
+ /**
+ * The form to add or change a reason
+ *
+ * @param int $reason_id 0 to add a reason
+ * @return void
+ */
+ protected function edit_reason($reason_id)
+ {
+ global $config, $language, $request, $template;
+
+ $reason = [
+ 'reason_title' => '',
+ 'reason_description' => '',
+ 'reason_color' => \phpbbmodders\seenotes\manager::DEFAULT_COLOR,
+ ];
+
+ if ($reason_id)
+ {
+ $reason = $this->get_reason_or_error($reason_id);
+ }
+
+ $errors = [];
+ if ($request->is_set_post('submit'))
+ {
+ $this->check_form();
+
+ $reason['reason_title'] = trim($request->variable('reason_title', '', true));
+ $reason['reason_description'] = trim($request->variable('reason_description', '', true));
+ $color = $request->variable('reason_color', '');
+
+ if ($reason['reason_title'] === '')
+ {
+ $errors[] = $language->lang('SEENOTES_TITLE_EMPTY');
+ }
+
+ $max = \phpbbmodders\seenotes\manager::TEXT_MAX_LENGTH;
+ // Checked as stored: request input is HTML-escaped, so "&" takes five characters of the column
+ if (utf8_strlen($reason['reason_title']) > $max || utf8_strlen($reason['reason_description']) > $max)
+ {
+ $errors[] = $language->lang('SEENOTES_TEXT_TOO_LONG', $max);
+ }
+
+ if ($reason['reason_description'] === '' && !empty($config['seenotes_require_description']))
+ {
+ $errors[] = $language->lang('SEENOTES_DESCRIPTION_REQUIRED');
+ }
+
+ if (!preg_match('/\A#?[0-9A-Fa-f]{6}\z/', $color))
+ {
+ $errors[] = $language->lang('SEENOTES_COLOR_INVALID');
+ }
+ $reason['reason_color'] = $this->manager->clean_color($color);
+
+ if (!$errors)
+ {
+ $this->manager->save_reason($reason, $reason_id);
+ $this->manager->log_admin($reason_id ? 'LOG_SEENOTES_REASON_EDITED' : 'LOG_SEENOTES_REASON_ADDED', [$reason['reason_title']]);
+
+ trigger_error($language->lang($reason_id ? 'SEENOTES_REASON_UPDATED' : 'SEENOTES_REASON_ADDED') . adm_back_link($this->u_action));
+ }
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_EDIT' => true,
+ 'S_SEENOTES_ADDING' => !$reason_id,
+ 'S_SEENOTES_REQUIRE_DESCRIPTION'=> !empty($config['seenotes_require_description']),
+ 'SEENOTES_ERROR' => implode(' ', $errors),
+ 'REASON_TITLE' => $reason['reason_title'],
+ 'REASON_DESCRIPTION' => $reason['reason_description'],
+ 'REASON_COLOR' => $this->manager->clean_color($reason['reason_color']),
+ 'REASON_MAX_LENGTH' => \phpbbmodders\seenotes\manager::TEXT_MAX_LENGTH,
+ 'U_ACTION' => $this->u_action . '&action=' . ($reason_id ? 'edit&id=' . $reason_id : 'add'),
+ 'U_BACK' => $this->u_action,
+ ]);
+ }
+
+ /**
+ * Move a reason, or switch it on or off, from a link in the list
+ *
+ * @param string $action move_up, move_down, enable or disable
+ * @param int $reason_id
+ * @return void
+ */
+ protected function quick_action($action, $reason_id)
+ {
+ global $language, $request;
+
+ if (!check_link_hash($request->variable('hash', ''), 'seenotes_reasons'))
+ {
+ $this->error($language->lang('FORM_INVALID'));
+ }
+
+ $reason = $this->get_reason_or_error($reason_id);
+
+ switch ($action)
+ {
+ case 'move_up':
+ case 'move_down':
+ $this->manager->move_reason($reason_id, $action === 'move_up');
+ break;
+
+ case 'enable':
+ case 'disable':
+ $this->manager->set_reason_active($reason_id, $action === 'enable');
+ $this->manager->log_admin($action === 'enable' ? 'LOG_SEENOTES_REASON_ENABLED' : 'LOG_SEENOTES_REASON_DISABLED', [$reason['reason_title']]);
+ break;
+ }
+ }
+
+ /**
+ * Delete a reason with all its entries: a screen with what will be
+ * removed, a screen where the reason's name is typed, then the deletion
+ *
+ * @param string $action delete, delete_confirm or delete_final
+ * @param int $reason_id
+ * @return void
+ */
+ protected function delete_reason($action, $reason_id)
+ {
+ global $config, $language, $request, $template;
+
+ if (empty($config['seenotes_allow_hard_delete']))
+ {
+ $this->error($language->lang('SEENOTES_HARD_DELETE_OFF'));
+ }
+
+ $reason = $this->get_reason_or_error($reason_id);
+ $usage = $this->manager->count_reason_usage($reason_id);
+
+ if ($request->is_set_post('cancel'))
+ {
+ redirect(str_replace('&', '&', $this->u_action));
+ }
+
+ if ($action !== 'delete')
+ {
+ $this->check_form();
+ }
+
+ if ($action === 'delete_final')
+ {
+ $typed = trim($request->variable('confirm_name', '', true));
+ if ($typed !== $reason['reason_title'])
+ {
+ $this->error($language->lang('SEENOTES_DELETE_NAME_MISMATCH'));
+ }
+
+ $this->manager->delete_reason($reason_id);
+ $this->manager->log_admin('LOG_SEENOTES_REASON_DELETED', [$reason['reason_title'], $usage['users'], $usage['entries'], $usage['notes']]);
+
+ trigger_error($language->lang('SEENOTES_REASON_DELETED') . adm_back_link($this->u_action));
+ }
+
+ $template->assign_vars([
+ 'S_SEENOTES_DELETE_STEP1' => $action === 'delete',
+ 'S_SEENOTES_DELETE_STEP2' => $action === 'delete_confirm',
+ 'REASON_TITLE' => $reason['reason_title'],
+ 'SEENOTES_DELETE_SUMMARY' => $language->lang('SEENOTES_DELETE_SUMMARY',
+ $reason['reason_title'],
+ $language->lang('SEENOTES_COUNT_USERS', $usage['users']),
+ $language->lang('SEENOTES_COUNT_ENTRIES', $usage['entries']),
+ $language->lang('SEENOTES_COUNT_NOTES', $usage['notes'])
+ ),
+ 'U_ACTION' => $this->u_action . '&action=' . ($action === 'delete' ? 'delete_confirm' : 'delete_final') . '&id=' . $reason_id,
+ ]);
+ }
+
+ /**
+ * A reason, or an error
+ *
+ * @param int $reason_id
+ * @return array
+ */
+ protected function get_reason_or_error($reason_id)
+ {
+ global $language;
+
+ $reason = $this->manager->get_reason($reason_id);
+ if ($reason === null)
+ {
+ $this->error($language->lang('SEENOTES_REASON_NOT_FOUND'));
+ }
+
+ return $reason;
+ }
+
+ /**
+ * Stop when the form key is not valid
+ *
+ * @return void
+ */
+ protected function check_form()
+ {
+ global $language;
+
+ if (!check_form_key(self::FORM_KEY))
+ {
+ $this->error($language->lang('FORM_INVALID'));
+ }
+ }
+
+ /**
+ * Show an error with a link back to the list
+ *
+ * @param string $message
+ * @return void
+ */
+ protected function error($message)
+ {
+ trigger_error($message . adm_back_link($this->u_action), E_USER_WARNING);
+ }
+}
diff --git a/adm/style/acp_seenotes_reasons.html b/adm/style/acp_seenotes_reasons.html
new file mode 100644
index 0000000..ae27996
--- /dev/null
+++ b/adm/style/acp_seenotes_reasons.html
@@ -0,0 +1,104 @@
+{% include 'overall_header.html' %}
+
+{{ lang('ACP_SEENOTES_REASONS') }}
+
+{% if S_SEENOTES_EDIT %}
+
+ « {{ lang('BACK') }}
+ {% if S_SEENOTES_ADDING %}{{ lang('SEENOTES_ADD_REASON') }}{% else %}{{ lang('SEENOTES_EDIT_REASON') }}{% endif %}
+
+ {% if SEENOTES_ERROR %}{% endif %}
+
+
+
+{% elseif S_SEENOTES_DELETE_STEP1 or S_SEENOTES_DELETE_STEP2 %}
+
+ {{ lang('SEENOTES_DELETE_REASON') }}
+
+
{{ SEENOTES_DELETE_SUMMARY }}
+
+
+
+ {% if S_SEENOTES_DELETE_STEP2 %}{% INCLUDEJS '@phpbbmodders_seenotes/seenotes_delete.js' %}{% endif %}
+
+{% else %}
+
+ {{ lang('ACP_SEENOTES_REASONS_EXPLAIN') }}
+
+ {% if loops.reasons|length %}
+
+ {% else %}
+ {{ lang('SEENOTES_NO_REASONS_YET') }}
+ {% endif %}
+
+ {{ lang('SEENOTES_ADD_REASON') }}
+
+{% endif %}
+
+{% include 'overall_footer.html' %}
diff --git a/adm/style/acp_seenotes_settings.html b/adm/style/acp_seenotes_settings.html
new file mode 100644
index 0000000..6840a94
--- /dev/null
+++ b/adm/style/acp_seenotes_settings.html
@@ -0,0 +1,31 @@
+{% include 'overall_header.html' %}
+
+{{ lang('ACP_SEENOTES') }}
+{{ lang('ACP_SEENOTES_SETTINGS_EXPLAIN') }}
+
+
+
+{% include 'overall_footer.html' %}
diff --git a/adm/style/acp_users_seenotes.html b/adm/style/acp_users_seenotes.html
new file mode 100644
index 0000000..cb8dea1
--- /dev/null
+++ b/adm/style/acp_users_seenotes.html
@@ -0,0 +1,188 @@
+{% if SEENOTES_ERROR %}
+
+{% endif %}
+
+
+ {{ lang('SEENOTES_ENTRIES') }}
+
+ {% for entry in loops.seenotes_entries %}
+
+
+ {{ entry.REASON }}
+ {% if entry.S_CURRENT %} ({{ lang('SEENOTES_CURRENT') }}) {% endif %}
+ {% if not entry.S_ACTIVE %} ({{ lang('SEENOTES_INACTIVE') }}) {% endif %}
+
+ {% if entry.DESCRIPTION %}{{ entry.DESCRIPTION }}
{% endif %}
+ {{ lang('SEENOTES_ADDED_BY', entry.AUTHOR, entry.TIME) }} • {{ entry.NOTE_COUNT }}
+
+ {% if S_SEENOTES_CAN_MANAGE or S_SEENOTES_CAN_DELETE_ENTRY %}
+
+ {% if S_SEENOTES_CAN_MANAGE %}
+ {{ lang('SEENOTES_ADD_NOTE') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_ENTRY %}
+ • {{ lang('SEENOTES_DELETE_ENTRY') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+
+ {% if S_SEENOTES_CAN_MANAGE and not entry.S_CURRENT %}
+
+ {% endif %}
+ {% endif %}
+
+ {% if entry.notes|length %}
+
+
+
+ {{ lang('SEENOTES_NOTE_TEXT') }}
+ {{ lang('REPORT_BY') }}
+ {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}{{ lang('ACTION') }} {% endif %}
+
+
+
+ {% for note in entry.notes %}
+
+
+ {{ note.TEXT }}
+ {% if note.EDITED %}{{ note.EDITED }}
{% endif %}
+
+ {{ note.AUTHOR }} {{ note.TIME }}
+ {% if S_SEENOTES_CAN_EDIT or S_SEENOTES_CAN_DELETE_NOTE %}
+
+ {% if S_SEENOTES_CAN_EDIT %}{{ lang('SEENOTES_EDIT_NOTE') }}, {{ note.TIME }} {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_NOTE %}{{ lang('SEENOTES_DELETE_NOTE') }}, {{ note.TIME }} {% endif %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_NOTES') }}
+ {% endif %}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_ENTRIES') }}
+ {% endfor %}
+
+ {% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
+
+ {% endif %}
+
+
+{% if S_SEENOTES_EDITOR %}
+
+
+{% endif %}
+
+{% if S_SEENOTES_CAN_MANAGE %}
+
+
+ {{ lang('SEENOTES_ADD_ENTRY') }}
+ {% if loops.seenotes_reasons|length %}
+ {{ lang('SEENOTES_ADD_ENTRY_EXPLAIN') }}
+
+ {{ lang('SEENOTES_REASON') }}{{ lang('COLON') }}
+
+
+ {{ lang('SEENOTES_SELECT_REASON') }}
+ {% for reason in loops.seenotes_reasons %}
+ {{ reason.TITLE }}
+ {% endfor %}
+
+
+
+
+ {{ lang('SEENOTES_MAKE_IT_CURRENT') }}{{ lang('COLON') }}
+
+
+
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% else %}
+ {{ lang('SEENOTES_NO_REASONS') }}
+ {% endif %}
+
+
+{% endif %}
+
+
+ {{ lang('SEENOTES_USERNOTES') }}
+ {{ lang('SEENOTES_USERNOTES_EXPLAIN') }}
+ {% if loops.seenotes_usernotes|length %}
+
+
+
+ {{ lang('REPORT_BY') }}
+ {{ lang('TIME') }}
+ {{ lang('ACTION') }}
+
+
+
+ {% for usernote in loops.seenotes_usernotes %}
+
+ {{ usernote.REPORT_BY }}
+ {{ usernote.REPORT_AT }}
+ {{ usernote.ACTION }}
+
+ {% endfor %}
+
+
+ {{ SEENOTES_USERNOTES_TOTAL }} • {{ lang('SEENOTES_USERNOTES_ALL') }}
+ {% else %}
+ {{ lang('SEENOTES_NO_USERNOTES') }}
+ {% endif %}
+
diff --git a/adm/style/event/acp_overall_header_head_append.html b/adm/style/event/acp_overall_header_head_append.html
new file mode 100644
index 0000000..e638549
--- /dev/null
+++ b/adm/style/event/acp_overall_header_head_append.html
@@ -0,0 +1 @@
+{% INCLUDECSS '@phpbbmodders_seenotes/seenotes_acp.css' %}
diff --git a/adm/style/event/acp_users_mode_add.html b/adm/style/event/acp_users_mode_add.html
new file mode 100644
index 0000000..e71256d
--- /dev/null
+++ b/adm/style/event/acp_users_mode_add.html
@@ -0,0 +1,3 @@
+{% if S_SEENOTES_PAGE %}
+ {% include '@phpbbmodders_seenotes/acp_users_seenotes.html' %}
+{% endif %}
diff --git a/adm/style/seenotes_acp.css b/adm/style/seenotes_acp.css
new file mode 100644
index 0000000..8579585
--- /dev/null
+++ b/adm/style/seenotes_acp.css
@@ -0,0 +1,46 @@
+/* See Notes: ACP helpers. The ACP has no screen-reader-only class of its own. */
+
+.sr-only {
+ border: 0 !important;
+ clip: rect(0, 0, 0, 0) !important;
+ height: 1px !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ padding: 0 !important;
+ position: absolute !important;
+ white-space: nowrap !important;
+ width: 1px !important;
+}
+
+.seenotes-entry h3 small {
+ font-weight: normal;
+}
+
+.seenotes-entry .content {
+ overflow-wrap: break-word;
+}
+
+/* The ACP has no BBCode styles, so give quotes in notes a frame */
+.seenotes-entry .content blockquote {
+ background-color: #F6F6F0;
+ border: 1px solid #DBDBCE;
+ margin: 6px 1px 6px 12px;
+ padding: 5px 8px;
+}
+
+.seenotes-entry .content blockquote cite {
+ display: block;
+ font-style: normal;
+ font-weight: bold;
+}
+
+/* Links large enough to tap (WCAG 2.2 target size) */
+.seenotes-note-actions {
+ white-space: nowrap;
+}
+
+.seenotes-note-actions a {
+ display: inline-block;
+ min-height: 24px;
+ line-height: 24px;
+}
diff --git a/adm/style/seenotes_delete.js b/adm/style/seenotes_delete.js
new file mode 100644
index 0000000..39739b5
--- /dev/null
+++ b/adm/style/seenotes_delete.js
@@ -0,0 +1,23 @@
+/**
+ * See Notes: keep the final delete button disabled until the reason's name
+ * is typed exactly. The server checks the name again, so this only helps.
+ */
+(function () {
+ 'use strict';
+
+ var input = document.getElementById('confirm_name');
+ var button = document.getElementById('seenotes_delete_final');
+
+ if (!input || !button) {
+ return;
+ }
+
+ var name = input.getAttribute('data-seenotes-name');
+
+ function update() {
+ button.disabled = input.value.trim() !== name;
+ }
+
+ input.addEventListener('input', update);
+ update();
+})();
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..ad1f4fb
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "phpbbmodders/seenotes",
+ "type": "phpbb-extension",
+ "description": "Lets moderators mark a member with a staff-only reason from a list the administrators set, and keep dated notes under each reason.",
+ "homepage": "https://www.phpbbmodders.com/",
+ "version": "1.0.0-dev",
+ "keywords": [
+ "phpbb",
+ "extension",
+ "moderation",
+ "moderator",
+ "user notes"
+ ],
+ "license": "GPL-2.0-only",
+ "authors": [
+ {
+ "name": "phpBB Modders",
+ "email": "board@phpbbmodders.com",
+ "homepage": "https://www.phpbbmodders.com/",
+ "role": "Extension Developer"
+ }
+ ],
+ "require": {
+ "php": ">=8.3.36",
+ "composer/installers": "^1.0 || ^2.0"
+ },
+ "extra": {
+ "display-name": "See Notes",
+ "soft-require": {
+ "phpbb/phpbb": ">=3.3.19,<4.1.0@dev"
+ }
+ }
+}
diff --git a/config/services.yml b/config/services.yml
new file mode 100644
index 0000000..3409fff
--- /dev/null
+++ b/config/services.yml
@@ -0,0 +1,43 @@
+parameters:
+ tables.phpbbmodders.seenotes.reasons: '%core.table_prefix%seenotes_reasons'
+ tables.phpbbmodders.seenotes.entries: '%core.table_prefix%seenotes_entries'
+ tables.phpbbmodders.seenotes.notes: '%core.table_prefix%seenotes_notes'
+
+services:
+ phpbbmodders.seenotes.manager:
+ class: phpbbmodders\seenotes\manager
+ arguments:
+ - '@auth'
+ - '@config'
+ - '@dbal.conn'
+ - '@log'
+ - '@user'
+ - '%tables.phpbbmodders.seenotes.reasons%'
+ - '%tables.phpbbmodders.seenotes.entries%'
+ - '%tables.phpbbmodders.seenotes.notes%'
+
+ phpbbmodders.seenotes.notes_page:
+ class: phpbbmodders\seenotes\notes_page
+ arguments:
+ - '@language'
+ - '@log'
+ - '@request'
+ - '@template'
+ - '@user'
+ - '@phpbbmodders.seenotes.manager'
+ - '%core.root_path%'
+ - '%core.php_ext%'
+
+ phpbbmodders.seenotes.listener:
+ class: phpbbmodders\seenotes\event\listener
+ arguments:
+ - '@auth'
+ - '@language'
+ - '@template'
+ - '@user'
+ - '@phpbbmodders.seenotes.manager'
+ - '@phpbbmodders.seenotes.notes_page'
+ - '%core.root_path%'
+ - '%core.php_ext%'
+ tags:
+ - { name: event.listener }
diff --git a/docs/design.md b/docs/design.md
index 5955d0d..43f4188 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,6 +1,6 @@
# See Notes: design
-Status: **design stage, no code yet.** This document records what has been agreed so far, so it can be reviewed before work starts. Feedback goes in the design issue.
+This document records the agreed design. The extension follows it; changes to the design go in an issue or discussion first.
## What it does
@@ -24,7 +24,7 @@ The display still looks like a profile field.
**ACP → Extensions → See Notes → Reasons**:
-- Administrators add, edit and reorder reasons. The list starts empty.
+- Administrators add, edit and reorder reasons. Enabling the extension the first time adds seven starter reasons in the board's default language: Moderation queue, Spam watch, Warez, Conduct, Possible returning member, Staff in contact and General, each with a description and color. They are ordinary reasons from then on. A board that already has reasons gets none.
- Each reason has a **description** (for example "Posts must be approved before they appear"), shown to staff when they hover over the reason, and a **color**, red by default, used for the mini-profile line. The **Require a description for each reason** setting (off by default) makes the description required: when it is on, a reason cannot be saved without one. Turning it on does not change existing reasons, but one without a description must get one the next time it is edited.
- Each reason has an **on/off switch**. Turning a reason off removes it from the list moderators pick from. Members who already have it keep their entries and notes, still shown with that reason's name. Turning it back on makes it selectable again.
- Renaming a reason renames it everywhere it is used, since entries point to the reason rather than copying its name.
@@ -43,10 +43,10 @@ The display still looks like a profile field.
## Entries and notes
-- A member can have **any number of reason entries**. Each entry records its reason, the date it was added and who added it.
+- A member can have **any number of reason entries**. Each entry records its reason, the date it was added and who added it (the name is saved, like a note author's).
- Entries are kept until someone deletes them.
- **At most one entry is current.** Staff can switch which entry is current, or set none. When none is current, nothing shows on the member's profile, but the entries and notes are still there in the MCP.
-- Each entry has its own list of **notes**, like phpBB's user notes: author, date and text. Notes are plain text, shown escaped. Notes can be edited and deleted; an edited note shows who last edited it and when.
+- Each entry has its own list of **notes**, like phpBB's user notes: author, date and text. Notes use the board's normal BBCode handling, the same way private messages do: standard and custom BBCodes, smilies and automatic links, each following the board's own on/off setting. The note form has the usual BBCode buttons, so a private message sent to the member can be pasted in a quote for other staff to read. Notes can be edited and deleted; an edited note shows who last edited it and when.
- Deleting an entry deletes its notes.
- The author's and last editor's usernames are saved with each note. While they are members, their name links to their profile. If one of them is deleted, the note keeps their name as plain text with no link, the way phpBB keeps a deleted member's name on their posts. Saved names are updated when a member is renamed (`core.update_username`).
- When the member the notes are about is deleted, their entries and notes are deleted too.
@@ -59,6 +59,7 @@ To staff with the "see" permission only:
- **Mini-profile** next to posts (viewtopic): the same line next to the custom profile fields, in bold, in the reason's color (red by default).
- **MCP pages where moderators act on a post:** the post details, report details and approval queue details pages, as a notice above the post (where phpBB shows "This post was reported"), and the MCP topic view, after each poster's name. The queue and reports *lists* are left out: their templates have no events to add anything to, and every item in them links to its details page.
- All of these show the reason's description on hover (when it has one) and link to the member's Moderator notes tab in the MCP. The number in brackets is how many notes the current entry has.
+- When a member has no current reason, the same places show ": NONE" in the normal text color. NONE links straight to the "Give a reason" form on the member's notes page, so staff can add one in one click.
Events used, all present on both 3.3.x and master:
@@ -104,8 +105,8 @@ Moderator permissions can be set globally or per forum. Having one in any forum
| Table | Columns |
|---|---|
| `seenotes_reasons` | `reason_id`, `reason_title`, `reason_description`, `reason_color`, `reason_order`, `reason_active` |
-| `seenotes_entries` | `entry_id`, `user_id`, `reason_id`, `entry_time`, `entry_author_id`, `entry_current` |
-| `seenotes_notes` | `note_id`, `entry_id`, `note_author_id`, `note_author_name`, `note_time`, `note_text`, `note_edit_time`, `note_edit_user_id`, `note_edit_user_name` |
+| `seenotes_entries` | `entry_id`, `user_id`, `reason_id`, `entry_time`, `entry_author_id`, `entry_author_name`, `entry_current` |
+| `seenotes_notes` | `note_id`, `entry_id`, `note_author_id`, `note_author_name`, `note_time`, `note_text`, `note_bbcode_uid`, `note_bbcode_bitfield`, `note_bbcode_options`, `note_edit_time`, `note_edit_user_id`, `note_edit_user_name` |
Setting an entry as current clears the flag on the member's other entries in the same transaction.
@@ -115,7 +116,7 @@ Config: `seenotes_label` (the label), `seenotes_require_description` (off) and `
Other events: `core.permissions`, `core.delete_user_after`, `core.update_username`. No new phpBB events are needed.
-Same as the other phpbbmodders extensions: `ext.php` minimum version checks (phpBB 3.3.19, PHP 7.4), `extra.version-check` once a phpBB.com listing exists, the org's strict EPV workflow plus lint and tests, screen-reader text on icon-only controls, and language strings for everything shown.
+Same as the other phpbbmodders extensions: `ext.php` minimum version checks (phpBB 3.3.19, PHP 8.3.36), `extra.version-check` once a phpBB.com listing exists, the org's strict EPV workflow plus lint and tests, screen-reader text on icon-only controls, and language strings for everything shown.
## Privacy
diff --git a/docs/images/screenshots/README.md b/docs/images/screenshots/README.md
new file mode 100644
index 0000000..b932437
--- /dev/null
+++ b/docs/images/screenshots/README.md
@@ -0,0 +1,51 @@
+# See Notes screenshots
+
+Taken on phpBB 3.3 with prosilver, as an administrator, using seed-forum's standard and See Notes fixtures. Click an image for the full size.
+
+## Next to a post
+
+The current reason in the poster's mini-profile, in the reason's color, with the number of notes. It links to the member's notes.
+
+
+
+## Profile
+
+The reason with the other profile details.
+
+
+
+## No current reason
+
+A member without a current reason shows NONE, which links straight to the form that gives one.
+
+
+
+## MCP post details
+
+The reason above the post on the MCP post, report and approval queue pages.
+
+
+
+## MCP → Moderator notes
+
+A member's reasons, newest first, with their notes (a quoted PM, an edited note), the note form with BBCode buttons, the form to give a reason, and the latest phpBB user notes.
+
+
+
+## ACP → Manage users → Moderator notes
+
+The same records and actions for administrators.
+
+
+
+## ACP → Extensions → See Notes → Reasons
+
+The list of reasons, with one switched off.
+
+
+
+## ACP → Extensions → See Notes → Settings
+
+The label, the description requirement, and whether reasons can be deleted with all their entries.
+
+
diff --git a/docs/images/screenshots/seenotes-acp-reasons.png b/docs/images/screenshots/seenotes-acp-reasons.png
new file mode 100644
index 0000000..fffef40
Binary files /dev/null and b/docs/images/screenshots/seenotes-acp-reasons.png differ
diff --git a/docs/images/screenshots/seenotes-acp-settings.png b/docs/images/screenshots/seenotes-acp-settings.png
new file mode 100644
index 0000000..baff001
Binary files /dev/null and b/docs/images/screenshots/seenotes-acp-settings.png differ
diff --git a/docs/images/screenshots/seenotes-acp-users.png b/docs/images/screenshots/seenotes-acp-users.png
new file mode 100644
index 0000000..e4a6efa
Binary files /dev/null and b/docs/images/screenshots/seenotes-acp-users.png differ
diff --git a/docs/images/screenshots/seenotes-mcp-notes.png b/docs/images/screenshots/seenotes-mcp-notes.png
new file mode 100644
index 0000000..79707e8
Binary files /dev/null and b/docs/images/screenshots/seenotes-mcp-notes.png differ
diff --git a/docs/images/screenshots/seenotes-mcp-post.png b/docs/images/screenshots/seenotes-mcp-post.png
new file mode 100644
index 0000000..8570dbd
Binary files /dev/null and b/docs/images/screenshots/seenotes-mcp-post.png differ
diff --git a/docs/images/screenshots/seenotes-profile-none.png b/docs/images/screenshots/seenotes-profile-none.png
new file mode 100644
index 0000000..4d4e2a7
Binary files /dev/null and b/docs/images/screenshots/seenotes-profile-none.png differ
diff --git a/docs/images/screenshots/seenotes-profile.png b/docs/images/screenshots/seenotes-profile.png
new file mode 100644
index 0000000..6b1be0c
Binary files /dev/null and b/docs/images/screenshots/seenotes-profile.png differ
diff --git a/docs/images/screenshots/seenotes-viewtopic.png b/docs/images/screenshots/seenotes-viewtopic.png
new file mode 100644
index 0000000..6a055d7
Binary files /dev/null and b/docs/images/screenshots/seenotes-viewtopic.png differ
diff --git a/event/listener.php b/event/listener.php
new file mode 100644
index 0000000..8cf6425
--- /dev/null
+++ b/event/listener.php
@@ -0,0 +1,352 @@
+auth = $auth;
+ $this->language = $language;
+ $this->template = $template;
+ $this->user = $user;
+ $this->manager = $manager;
+ $this->notes_page = $notes_page;
+ $this->root_path = $root_path;
+ $this->php_ext = $php_ext;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents()
+ {
+ return [
+ 'core.user_setup' => 'load_language',
+ 'core.permissions' => 'add_permissions',
+ 'core.page_header' => 'load_stylesheet',
+ 'core.memberlist_view_profile' => 'show_on_profile',
+ 'core.viewtopic_modify_post_data' => 'load_viewtopic_posters',
+ 'core.viewtopic_modify_post_row' => 'show_on_post',
+ 'core.mcp_post_template_data' => 'show_on_mcp_post',
+ 'core.mcp_report_template_data' => 'show_on_mcp_post',
+ 'core.mcp_queue_approve_details_template' => 'show_on_mcp_post',
+ 'core.mcp_topic_modify_post_data' => 'load_mcp_topic_posters',
+ 'core.mcp_topic_review_modify_row' => 'show_on_mcp_topic_post',
+ 'core.acp_users_mode_add' => 'acp_users_page',
+ 'core.delete_user_after' => 'delete_users',
+ 'core.update_username' => 'update_username',
+ ];
+ }
+
+ /**
+ * Load the strings used across the board: log entries and the reason line
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_language(data $event)
+ {
+ $lang_set_ext = $event['lang_set_ext'];
+ $lang_set_ext[] = [
+ 'ext_name' => 'phpbbmodders/seenotes',
+ 'lang_set' => 'common',
+ ];
+ $event['lang_set_ext'] = $lang_set_ext;
+ }
+
+ /**
+ * Add the permissions to ACP > Permissions, in their own category
+ *
+ * @param data $event
+ * @return void
+ */
+ public function add_permissions(data $event)
+ {
+ $this->language->add_lang('permissions_seenotes', 'phpbbmodders/seenotes');
+
+ $categories = $event['categories'];
+ $categories['seenotes'] = 'ACL_CAT_SEENOTES';
+ $event['categories'] = $categories;
+
+ $permissions = $event['permissions'];
+ foreach (manager::ACTIONS as $action)
+ {
+ $permissions['m_seenotes_' . $action] = ['lang' => 'ACL_M_SEENOTES_' . strtoupper($action), 'cat' => 'seenotes'];
+ $permissions['a_seenotes_' . $action] = ['lang' => 'ACL_A_SEENOTES_' . strtoupper($action), 'cat' => 'seenotes'];
+ }
+ $permissions['a_seenotes_settings'] = ['lang' => 'ACL_A_SEENOTES_SETTINGS', 'cat' => 'seenotes'];
+ $event['permissions'] = $permissions;
+ }
+
+ /**
+ * Load the stylesheet for staff who may see reasons
+ *
+ * @return void
+ */
+ public function load_stylesheet()
+ {
+ $this->template->assign_var('S_SEENOTES_CSS', $this->manager->can('view'));
+ }
+
+ /**
+ * Show the member's current reason with the profile details
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_profile(data $event)
+ {
+ $this->template->assign_vars($this->get_display_vars((int) $event['member']['user_id']));
+ }
+
+ /**
+ * Read the current reasons of everyone who posted on a topic page in one
+ * query
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_viewtopic_posters(data $event)
+ {
+ if ($this->manager->can('view') && !empty($event['user_cache']))
+ {
+ $this->manager->get_current_for_users(array_keys($event['user_cache']));
+ }
+ }
+
+ /**
+ * Show the poster's current reason in their mini-profile
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_post(data $event)
+ {
+ $vars = $this->get_display_vars((int) $event['poster_id']);
+ if ($vars)
+ {
+ $event['post_row'] = array_merge($event['post_row'], $vars);
+ }
+ }
+
+ /**
+ * Show the poster's current reason above the post on the MCP post,
+ * report and approval queue details pages
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_mcp_post(data $event)
+ {
+ if (!empty($event['post_info']['poster_id']))
+ {
+ $this->template->assign_vars($this->get_display_vars((int) $event['post_info']['poster_id']));
+ }
+ }
+
+ /**
+ * Read the current reasons of everyone who posted on an MCP topic page in
+ * one query
+ *
+ * @param data $event
+ * @return void
+ */
+ public function load_mcp_topic_posters(data $event)
+ {
+ if ($this->manager->can('view') && !empty($event['rowset']))
+ {
+ $this->manager->get_current_for_users(array_column($event['rowset'], 'poster_id'));
+ }
+ }
+
+ /**
+ * Show the poster's current reason after their name in the MCP topic view
+ *
+ * @param data $event
+ * @return void
+ */
+ public function show_on_mcp_topic_post(data $event)
+ {
+ if (empty($event['row']['poster_id']))
+ {
+ return;
+ }
+
+ $vars = $this->get_display_vars((int) $event['row']['poster_id']);
+ if ($vars)
+ {
+ $event['post_row'] = array_merge($event['post_row'], $vars);
+ }
+ }
+
+ /**
+ * Show the Moderator notes page in ACP > Manage users
+ *
+ * @param data $event
+ * @return void
+ */
+ public function acp_users_page(data $event)
+ {
+ if ($event['mode'] !== 'seenotes')
+ {
+ return;
+ }
+
+ $this->notes_page->handle($event['user_row'], $event['u_action'] . '&u=' . (int) $event['user_id'], true);
+ }
+
+ /**
+ * Delete the entries and notes about deleted members
+ *
+ * @param data $event
+ * @return void
+ */
+ public function delete_users(data $event)
+ {
+ $this->manager->delete_users($event['user_ids']);
+ }
+
+ /**
+ * Keep saved author names up to date when a member is renamed
+ *
+ * @param data $event
+ * @return void
+ */
+ public function update_username(data $event)
+ {
+ $this->manager->update_username($event['old_name'], $event['new_name']);
+ }
+
+ /**
+ * Template variables for a member's current reason, or "NONE" when they
+ * have none, for viewers who may see it
+ *
+ * @param int $user_id
+ * @return array Empty when the viewer may not see reasons
+ */
+ protected function get_display_vars($user_id)
+ {
+ if (!$user_id || $user_id == ANONYMOUS || !$this->manager->can('view'))
+ {
+ return [];
+ }
+
+ $current = $this->manager->get_current_for_users([$user_id]);
+ $u_notes = $this->get_notes_url($user_id);
+
+ // No current reason: "NONE", linking to the form that gives one
+ if (!isset($current[$user_id]))
+ {
+ $can_manage = $this->manager->can('manage');
+
+ return [
+ 'S_SEENOTES' => true,
+ 'S_SEENOTES_NONE' => true,
+ 'SEENOTES_LABEL' => $this->manager->get_label(),
+ 'SEENOTES_REASON' => $this->language->lang('SEENOTES_NONE'),
+ 'SEENOTES_LINK_TEXT' => $this->language->lang($can_manage ? 'SEENOTES_NONE_GIVE' : 'SEENOTES_NONE_EXPLAIN'),
+ 'U_SEENOTES' => ($u_notes && $can_manage) ? $u_notes . '#seenotes-add-entry' : $u_notes,
+ ];
+ }
+
+ $entry = $current[$user_id];
+
+ return [
+ 'S_SEENOTES' => true,
+ 'S_SEENOTES_NONE' => false,
+ 'SEENOTES_LABEL' => $this->manager->get_label(),
+ 'SEENOTES_REASON' => $entry['reason_title'],
+ 'SEENOTES_DESCRIPTION' => $entry['reason_description'],
+ 'SEENOTES_COLOR' => $this->manager->clean_color($entry['reason_color']),
+ 'SEENOTES_COUNT' => (int) $entry['note_count'],
+ 'SEENOTES_COUNT_TEXT' => $this->language->lang('SEENOTES_NOTE_COUNT', (int) $entry['note_count']),
+ 'SEENOTES_LINK_TEXT' => $this->language->lang('SEENOTES_OPEN_NOTES'),
+ 'U_SEENOTES' => $u_notes,
+ ];
+ }
+
+ /**
+ * Where the viewer can read the member's notes: the MCP for moderators,
+ * ACP > Manage users for administrators without moderator access
+ *
+ * @param int $user_id
+ * @return string Empty when the viewer can reach neither
+ */
+ protected function get_notes_url($user_id)
+ {
+ if ($this->auth->acl_getf_global('m_'))
+ {
+ return append_sid("{$this->root_path}mcp.{$this->php_ext}", 'i=' . self::MCP_MODULE . '&mode=notes&u=' . $user_id);
+ }
+
+ if ($this->auth->acl_get('a_') && $this->auth->acl_get('a_user') && $this->auth->acl_get('a_seenotes_view'))
+ {
+ return append_sid("{$this->root_path}adm/index.{$this->php_ext}", 'i=users&mode=seenotes&u=' . $user_id, true, $this->user->session_id);
+ }
+
+ return '';
+ }
+}
diff --git a/ext.php b/ext.php
new file mode 100644
index 0000000..f099110
--- /dev/null
+++ b/ext.php
@@ -0,0 +1,60 @@
+check_phpbb_version() && $this->check_php_version();
+
+ if (!$enableable)
+ {
+ $language = $this->container->get('language');
+ $language->add_lang('install_seenotes', 'phpbbmodders/seenotes');
+
+ return $language->lang('SEENOTES_NOT_ENABLEABLE');
+ }
+
+ return $enableable;
+ }
+
+ /**
+ * Require phpBB 3.3.19 due to several security fixes in the phpBB software.
+ *
+ * @return bool
+ */
+ public function check_phpbb_version()
+ {
+ return phpbb_version_compare(PHPBB_VERSION, '3.3.19', '>=');
+ }
+
+ /**
+ * Requires PHP 8.3.36 due to security fixes.
+ *
+ * @return bool
+ */
+ public function check_php_version()
+ {
+ return PHP_VERSION_ID >= 80336;
+ }
+}
diff --git a/language/en/acp_seenotes.php b/language/en/acp_seenotes.php
new file mode 100644
index 0000000..edd53d9
--- /dev/null
+++ b/language/en/acp_seenotes.php
@@ -0,0 +1,82 @@
+ 'Moderators give members one of these reasons and keep notes under it. A switched-off reason can no longer be given, but members who have it keep it and its notes.',
+ 'ACP_SEENOTES_SETTINGS_EXPLAIN' => 'The label shown before a member’s reason, and how reasons are managed.',
+
+ 'SEENOTES_ADD_REASON' => 'Add reason',
+ 'SEENOTES_EDIT_REASON' => 'Edit reason',
+ 'SEENOTES_NO_REASONS_YET' => 'There are no reasons yet.',
+ 'SEENOTES_REASON_TITLE' => 'Name',
+ 'SEENOTES_REASON_TITLE_EXPLAIN' => 'Shown after the label, for example “Moderation queue”.',
+ 'SEENOTES_REASON_DESCRIPTION' => 'Description',
+ 'SEENOTES_REASON_DESCRIPTION_EXPLAIN' => 'Shown to staff when they hover over the reason.',
+ 'SEENOTES_REASON_DESCRIPTION_REQUIRED' => 'Required on this board.',
+ 'SEENOTES_REASON_COLOR' => 'Color',
+ 'SEENOTES_REASON_COLOR_EXPLAIN' => 'Used for the line next to the member’s posts.',
+ 'SEENOTES_STATUS' => 'Status',
+ 'SEENOTES_STATUS_ON' => 'On',
+ 'SEENOTES_STATUS_OFF' => 'Off',
+ 'SEENOTES_SWITCH_ON' => 'Switch on',
+ 'SEENOTES_SWITCH_OFF' => 'Switch off',
+ 'SEENOTES_MOVE_UP' => 'Move up',
+ 'SEENOTES_MOVE_DOWN' => 'Move down',
+ 'SEENOTES_DELETE_REASON' => 'Delete with all entries',
+
+ 'SEENOTES_TITLE_EMPTY' => 'Enter a name for the reason.',
+ 'SEENOTES_TEXT_TOO_LONG' => 'The name and description can each be at most %d characters long.',
+ 'SEENOTES_DESCRIPTION_REQUIRED' => 'Enter a description. This board requires one for each reason.',
+ 'SEENOTES_COLOR_INVALID' => 'The color must be a hex color such as #AA0000.',
+ 'SEENOTES_REASON_ADDED' => 'The reason has been added.',
+ 'SEENOTES_REASON_UPDATED' => 'The reason has been changed.',
+ 'SEENOTES_REASON_NOT_FOUND' => 'That reason does not exist.',
+
+ 'SEENOTES_HARD_DELETE_OFF' => 'Deleting reasons is switched off in the See Notes settings.',
+ 'SEENOTES_DELETE_SUMMARY' => 'Delete reason “%1$s”? This will permanently remove it from %2$s, along with %3$s and %4$s.',
+ 'SEENOTES_COUNT_USERS' => [
+ 1 => '%d member',
+ 2 => '%d members',
+ ],
+ 'SEENOTES_COUNT_ENTRIES' => [
+ 1 => '%d entry',
+ 2 => '%d entries',
+ ],
+ 'SEENOTES_COUNT_NOTES' => [
+ 1 => '%d note',
+ 2 => '%d notes',
+ ],
+ 'SEENOTES_DELETE_CONTINUE' => 'Continue',
+ 'SEENOTES_DELETE_TYPE_NAME' => 'To confirm, type the reason’s name: %s',
+ 'SEENOTES_DELETE_FINAL' => 'Delete this reason and all its entries',
+ 'SEENOTES_DELETE_NAME_MISMATCH' => 'The name you typed does not match the reason’s name. Nothing was deleted.',
+ 'SEENOTES_REASON_DELETED' => 'The reason and all its entries and notes have been deleted.',
+
+ 'SEENOTES_LABEL' => 'Label',
+ 'SEENOTES_LABEL_EXPLAIN' => 'Shown before the reason, for example “SEE NOTES: Moderation queue”. One name for the whole board.',
+ 'SEENOTES_LABEL_INVALID' => 'Enter a label of at most %d characters.',
+ 'SEENOTES_REQUIRE_DESCRIPTION' => 'Require a description for each reason',
+ 'SEENOTES_REQUIRE_DESCRIPTION_EXPLAIN' => 'Existing reasons without one must get one the next time they are edited.',
+ 'SEENOTES_ALLOW_HARD_DELETE' => 'Allow deleting a reason with all its entries',
+ 'SEENOTES_ALLOW_HARD_DELETE_EXPLAIN' => 'Adds a delete button to each reason. Deleting permanently removes the reason from every member, with all their entries and notes under it, and asks you to type the reason’s name first.',
+]);
diff --git a/language/en/common.php b/language/en/common.php
new file mode 100644
index 0000000..af95791
--- /dev/null
+++ b/language/en/common.php
@@ -0,0 +1,50 @@
+ [
+ 0 => 'No notes',
+ 1 => '%d note',
+ 2 => '%d notes',
+ ],
+ 'SEENOTES_OPEN_NOTES' => 'Read the moderator notes about this member',
+ 'SEENOTES_NONE' => 'NONE',
+ 'SEENOTES_NONE_GIVE' => 'This member has no current reason. Give them one',
+ 'SEENOTES_NONE_EXPLAIN' => 'This member has no current reason. Read the moderator notes about them',
+
+ 'LOG_SEENOTES_ENTRY_ADDED' => 'Gave a member a See Notes reason » %1$s, to %2$s',
+ 'LOG_SEENOTES_CURRENT_SET' => 'Changed a member’s current See Notes reason » %1$s, for %2$s',
+ 'LOG_SEENOTES_CURRENT_CLEARED' => 'Cleared a member’s current See Notes reason » %s',
+ 'LOG_SEENOTES_ENTRY_DELETED' => 'Deleted a member’s See Notes reason with its notes » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_ADDED' => 'Added a See Notes note » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_EDITED' => 'Edited a See Notes note » %1$s, for %2$s',
+ 'LOG_SEENOTES_NOTE_DELETED' => 'Deleted a See Notes note » %1$s, for %2$s',
+
+ 'LOG_SEENOTES_REASON_ADDED' => 'Added a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_EDITED' => 'Changed a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_ENABLED' => 'Switched on a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_DISABLED' => 'Switched off a See Notes reason » %s',
+ 'LOG_SEENOTES_REASON_DELETED' => 'Deleted a See Notes reason with all its entries » %1$s (members: %2$d, entries: %3$d, notes: %4$d)',
+ 'LOG_SEENOTES_SETTINGS' => 'Changed the See Notes settings ',
+]);
diff --git a/language/en/info_acp_seenotes.php b/language/en/info_acp_seenotes.php
new file mode 100644
index 0000000..495f4d4
--- /dev/null
+++ b/language/en/info_acp_seenotes.php
@@ -0,0 +1,29 @@
+ 'See Notes',
+ 'ACP_SEENOTES_REASONS' => 'Reasons',
+ 'ACP_SEENOTES_SETTINGS' => 'Settings',
+ 'ACP_USER_SEENOTES' => 'Moderator notes',
+]);
diff --git a/language/en/info_mcp_seenotes.php b/language/en/info_mcp_seenotes.php
new file mode 100644
index 0000000..6dd904f
--- /dev/null
+++ b/language/en/info_mcp_seenotes.php
@@ -0,0 +1,27 @@
+ 'Moderator notes',
+ 'MCP_SEENOTES_NOTES' => 'Moderator notes',
+]);
diff --git a/language/en/install_seenotes.php b/language/en/install_seenotes.php
new file mode 100644
index 0000000..eb808a0
--- /dev/null
+++ b/language/en/install_seenotes.php
@@ -0,0 +1,44 @@
+ 'See Notes could not be enabled. It needs phpBB 3.3.19 or later and PHP 8.3.36 or later.',
+
+ // Starter reasons, added once when the extension is first enabled. They
+ // are stored in the board's default language and can then be edited in
+ // the ACP like any other reason.
+ 'SEENOTES_DEFAULT_QUEUE' => 'Moderation queue',
+ 'SEENOTES_DEFAULT_QUEUE_EXPLAIN' => 'Posts must be approved before they appear',
+ 'SEENOTES_DEFAULT_SPAM' => 'Spam watch',
+ 'SEENOTES_DEFAULT_SPAM_EXPLAIN' => 'Check links and signatures in new posts',
+ 'SEENOTES_DEFAULT_WAREZ' => 'Warez',
+ 'SEENOTES_DEFAULT_WAREZ_EXPLAIN' => 'Has posted or asked for pirated software, cracks or serials; check links and attachments',
+ 'SEENOTES_DEFAULT_CONDUCT' => 'Conduct',
+ 'SEENOTES_DEFAULT_CONDUCT_EXPLAIN' => 'Earlier problems with how they treat other members',
+ 'SEENOTES_DEFAULT_RETURNING' => 'Possible returning member',
+ 'SEENOTES_DEFAULT_RETURNING_EXPLAIN' => 'May be a banned member back under a new name; compare IPs and emails',
+ 'SEENOTES_DEFAULT_CONTACT' => 'Staff in contact',
+ 'SEENOTES_DEFAULT_CONTACT_EXPLAIN' => 'A staff member is talking to them by PM; check the notes before stepping in',
+ 'SEENOTES_DEFAULT_GENERAL' => 'General',
+ 'SEENOTES_DEFAULT_GENERAL_EXPLAIN' => 'Notes that do not fit another reason',
+]);
diff --git a/language/en/permissions_seenotes.php b/language/en/permissions_seenotes.php
new file mode 100644
index 0000000..149a989
--- /dev/null
+++ b/language/en/permissions_seenotes.php
@@ -0,0 +1,39 @@
+ 'Moderator notes',
+
+ 'ACL_M_SEENOTES_VIEW' => 'Can see members’ moderator notes reasons and notes',
+ 'ACL_M_SEENOTES_MANAGE' => 'Can give members a reason, set the current reason and add notes',
+ 'ACL_M_SEENOTES_NOTE_DELETE' => 'Can delete moderator notes',
+ 'ACL_M_SEENOTES_NOTE_EDIT' => 'Can edit moderator notes',
+ 'ACL_M_SEENOTES_ENTRY_DELETE' => 'Can delete a member’s reason with its notes',
+
+ 'ACL_A_SEENOTES_VIEW' => 'Can see members’ moderator notes reasons and notes',
+ 'ACL_A_SEENOTES_MANAGE' => 'Can give members a reason, set the current reason and add notes',
+ 'ACL_A_SEENOTES_NOTE_DELETE' => 'Can delete moderator notes',
+ 'ACL_A_SEENOTES_NOTE_EDIT' => 'Can edit moderator notes',
+ 'ACL_A_SEENOTES_ENTRY_DELETE' => 'Can delete a member’s reason with its notes',
+ 'ACL_A_SEENOTES_SETTINGS' => 'Can manage See Notes reasons and settings',
+]);
diff --git a/language/en/seenotes.php b/language/en/seenotes.php
new file mode 100644
index 0000000..bf40dd8
--- /dev/null
+++ b/language/en/seenotes.php
@@ -0,0 +1,83 @@
+ 'Enter a member’s username to see the reasons and notes staff have recorded about them.',
+ 'SEENOTES_ENTRIES' => 'Reasons',
+ 'SEENOTES_NO_ENTRIES' => 'This member has no reasons.',
+ 'SEENOTES_NO_NOTES' => 'No notes under this reason yet.',
+ 'SEENOTES_CURRENT' => 'Current',
+ 'SEENOTES_INACTIVE' => 'Switched off',
+ 'SEENOTES_INACTIVE_EXPLAIN' => 'This reason is switched off. It can no longer be given, but this entry and its notes are kept.',
+ 'SEENOTES_ADDED_BY' => 'Added by %1$s on %2$s',
+ 'SEENOTES_NOTE_BY' => 'By %1$s on %2$s',
+ 'SEENOTES_NOTE_EDITED_BY' => 'Last edited by %1$s on %2$s',
+ 'SEENOTES_MAKE_CURRENT' => 'Make current',
+ 'SEENOTES_CLEAR_CURRENT' => 'Show no reason',
+ 'SEENOTES_CLEAR_CURRENT_EXPLAIN' => 'Keeps all reasons and notes, but shows nothing on the member’s profile and posts.',
+ 'SEENOTES_ADD_NOTE' => 'Add a note',
+ 'SEENOTES_EDIT_NOTE' => 'Edit note',
+ 'SEENOTES_DELETE_NOTE' => 'Delete note',
+ 'SEENOTES_DELETE_ENTRY' => 'Delete reason and notes',
+ 'SEENOTES_DELETE_ENTRY_CONFIRM' => 'Delete the reason “%s” from this member, with all its notes?',
+ 'SEENOTES_DELETE_NOTE_CONFIRM' => 'Delete this note?',
+
+ 'SEENOTES_ADD_ENTRY' => 'Give a reason',
+ 'SEENOTES_ADD_ENTRY_EXPLAIN' => 'Reasons are set up by administrators. Add notes under the reason once it is given.',
+ 'SEENOTES_REASON' => 'Reason',
+ 'SEENOTES_SELECT_REASON' => 'Select a reason',
+ 'SEENOTES_MAKE_IT_CURRENT' => 'Make it the current reason',
+ 'SEENOTES_NO_REASONS' => 'There are no reasons to give. Administrators can add them in ACP > Extensions > See Notes > Reasons.',
+
+ 'SEENOTES_NOTE_FOR' => 'Under reason',
+ 'SEENOTES_NOTE_TEXT' => 'Note',
+ 'SEENOTES_ENTRY_OPTION' => '%1$s (%2$s)',
+ 'SEENOTES_FORMAT_STATUS' => 'BBCode is %1$s, smilies are %2$s, links are %3$s.',
+ 'SEENOTES_ON' => 'on',
+ 'SEENOTES_OFF' => 'off',
+ 'SEENOTES_SAVE_NOTE' => 'Save note',
+
+ 'SEENOTES_USERNOTES' => 'phpBB user notes',
+ 'SEENOTES_USERNOTES_EXPLAIN' => 'The latest user notes, including warnings and bans phpBB records itself. They are not changed here.',
+ 'SEENOTES_USERNOTES_TOTAL' => [
+ 0 => 'No user notes',
+ 1 => '%d user note',
+ 2 => '%d user notes',
+ ],
+ 'SEENOTES_USERNOTES_ALL' => 'See all user notes',
+ 'SEENOTES_NO_USERNOTES' => 'This member has no user notes.',
+
+ 'SEENOTES_ENTRY_ADDED' => 'The reason has been given.',
+ 'SEENOTES_CURRENT_SET' => 'The current reason has been changed.',
+ 'SEENOTES_CURRENT_CLEARED' => 'The member now has no current reason.',
+ 'SEENOTES_ENTRY_DELETED' => 'The reason and its notes have been deleted.',
+ 'SEENOTES_NOTE_ADDED' => 'The note has been added.',
+ 'SEENOTES_NOTE_EDITED' => 'The note has been changed.',
+ 'SEENOTES_NOTE_DELETED' => 'The note has been deleted.',
+
+ 'SEENOTES_REASON_NOT_SELECTED' => 'Select a reason that is switched on.',
+ 'SEENOTES_ENTRY_NOT_FOUND' => 'That reason entry does not exist for this member.',
+ 'SEENOTES_NOTE_NOT_FOUND' => 'That note does not exist for this member.',
+ 'SEENOTES_NOTE_EMPTY' => 'The note is empty.',
+]);
diff --git a/manager.php b/manager.php
new file mode 100644
index 0000000..5fc447d
--- /dev/null
+++ b/manager.php
@@ -0,0 +1,839 @@
+auth = $auth;
+ $this->config = $config;
+ $this->db = $db;
+ $this->log = $log;
+ $this->user = $user;
+ $this->reasons_table = $reasons_table;
+ $this->entries_table = $entries_table;
+ $this->notes_table = $notes_table;
+ }
+
+ /**
+ * Whether the current user may take an action: the moderator permission
+ * in any forum, or the matching administrator permission
+ *
+ * @param string $action One of self::ACTIONS
+ * @return bool
+ */
+ public function can($action)
+ {
+ if (!in_array($action, self::ACTIONS, true) || empty($this->user->data['is_registered']))
+ {
+ return false;
+ }
+
+ return (bool) $this->auth->acl_getf_global('m_seenotes_' . $action) || (bool) $this->auth->acl_get('a_seenotes_' . $action);
+ }
+
+ /**
+ * The label shown before the reason
+ *
+ * @return string
+ */
+ public function get_label()
+ {
+ $label = (string) $this->config['seenotes_label'];
+
+ return $label !== '' ? $label : 'SEE NOTES';
+ }
+
+ /**
+ * A reason color as six hex digits, or the default when it is not valid
+ *
+ * @param string $color
+ * @return string
+ */
+ public function clean_color($color)
+ {
+ $color = strtoupper(ltrim(trim((string) $color), '#'));
+
+ return preg_match('/\A[0-9A-F]{6}\z/', $color) ? $color : self::DEFAULT_COLOR;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Reasons */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * All reasons in their set order
+ *
+ * @param bool $active_only Leave out reasons that are switched off
+ * @return array Rows by reason ID
+ */
+ public function get_reasons($active_only = false)
+ {
+ $sql = 'SELECT *
+ FROM ' . $this->reasons_table .
+ ($active_only ? ' WHERE reason_active = 1' : '') . '
+ ORDER BY reason_order ASC, reason_id ASC';
+ $result = $this->db->sql_query($sql);
+
+ $reasons = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $reasons[(int) $row['reason_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $reasons;
+ }
+
+ /**
+ * One reason
+ *
+ * @param int $reason_id
+ * @return array|null
+ */
+ public function get_reason($reason_id)
+ {
+ $sql = 'SELECT *
+ FROM ' . $this->reasons_table . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Add a reason at the end of the list, or change one
+ *
+ * @param array $data reason_title, reason_description, reason_color
+ * @param int $reason_id 0 to add a reason
+ * @return int The reason's ID
+ */
+ public function save_reason(array $data, $reason_id = 0)
+ {
+ $sql_ary = [
+ 'reason_title' => (string) $data['reason_title'],
+ 'reason_description' => (string) $data['reason_description'],
+ 'reason_color' => $this->clean_color($data['reason_color']),
+ ];
+
+ if ($reason_id)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET ' . $this->db->sql_build_array('UPDATE', $sql_ary) . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $this->db->sql_query($sql);
+
+ return (int) $reason_id;
+ }
+
+ $sql = 'SELECT MAX(reason_order) AS max_order
+ FROM ' . $this->reasons_table;
+ $result = $this->db->sql_query($sql);
+ $max_order = (int) $this->db->sql_fetchfield('max_order');
+ $this->db->sql_freeresult($result);
+
+ $sql_ary['reason_order'] = $max_order + 1;
+ $sql_ary['reason_active'] = 1;
+
+ $this->db->sql_query('INSERT INTO ' . $this->reasons_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+
+ return (int) $this->db->sql_nextid();
+ }
+
+ /**
+ * Move a reason one place up or down the list
+ *
+ * @param int $reason_id
+ * @param bool $up
+ * @return bool Whether it moved
+ */
+ public function move_reason($reason_id, $up)
+ {
+ $ids = array_keys($this->get_reasons());
+ $position = array_search((int) $reason_id, $ids, true);
+ if ($position === false)
+ {
+ return false;
+ }
+
+ $swap = $up ? $position - 1 : $position + 1;
+ if (!isset($ids[$swap]))
+ {
+ return false;
+ }
+
+ list($ids[$position], $ids[$swap]) = [$ids[$swap], $ids[$position]];
+
+ // Renumber the whole list, which also repairs equal order values
+ $this->db->sql_transaction('begin');
+ foreach ($ids as $order => $id)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET reason_order = ' . ($order + 1) . '
+ WHERE reason_id = ' . (int) $id;
+ $this->db->sql_query($sql);
+ }
+ $this->db->sql_transaction('commit');
+
+ return true;
+ }
+
+ /**
+ * Switch a reason on or off
+ *
+ * @param int $reason_id
+ * @param bool $active
+ * @return void
+ */
+ public function set_reason_active($reason_id, $active)
+ {
+ $sql = 'UPDATE ' . $this->reasons_table . '
+ SET reason_active = ' . ($active ? 1 : 0) . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $this->db->sql_query($sql);
+ }
+
+ /**
+ * How many members, entries and notes use a reason
+ *
+ * @param int $reason_id
+ * @return array users, entries, notes
+ */
+ public function count_reason_usage($reason_id)
+ {
+ $sql = 'SELECT COUNT(DISTINCT user_id) AS users, COUNT(entry_id) AS entries
+ FROM ' . $this->entries_table . '
+ WHERE reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ $sql = 'SELECT COUNT(n.note_id) AS notes
+ FROM ' . $this->notes_table . ' n, ' . $this->entries_table . ' e
+ WHERE n.entry_id = e.entry_id
+ AND e.reason_id = ' . (int) $reason_id;
+ $result = $this->db->sql_query($sql);
+ $notes = (int) $this->db->sql_fetchfield('notes');
+ $this->db->sql_freeresult($result);
+
+ return [
+ 'users' => (int) $row['users'],
+ 'entries' => (int) $row['entries'],
+ 'notes' => $notes,
+ ];
+ }
+
+ /**
+ * Delete a reason with all its entries and their notes
+ *
+ * @param int $reason_id
+ * @return void
+ */
+ public function delete_reason($reason_id)
+ {
+ $entry_ids = $this->get_entry_ids('reason_id = ' . (int) $reason_id);
+
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows($entry_ids);
+ $this->db->sql_query('DELETE FROM ' . $this->reasons_table . ' WHERE reason_id = ' . (int) $reason_id);
+ $this->db->sql_transaction('commit');
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Entries */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * A member's entries, newest first, with their reason and author
+ *
+ * @param int $user_id
+ * @return array Rows by entry ID
+ */
+ public function get_entries($user_id)
+ {
+ $sql_ary = [
+ 'SELECT' => 'e.*, r.reason_title, r.reason_description, r.reason_color, r.reason_active, u.username, u.user_colour',
+ 'FROM' => [$this->entries_table => 'e'],
+ 'LEFT_JOIN' => [
+ [
+ 'FROM' => [$this->reasons_table => 'r'],
+ 'ON' => 'r.reason_id = e.reason_id',
+ ],
+ [
+ 'FROM' => [USERS_TABLE => 'u'],
+ 'ON' => 'u.user_id = e.entry_author_id',
+ ],
+ ],
+ 'WHERE' => 'e.user_id = ' . (int) $user_id,
+ 'ORDER_BY' => 'e.entry_time DESC, e.entry_id DESC',
+ ];
+ $result = $this->db->sql_query($this->db->sql_build_query('SELECT', $sql_ary));
+
+ $entries = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $entries[(int) $row['entry_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $entries;
+ }
+
+ /**
+ * One entry
+ *
+ * @param int $entry_id
+ * @return array|null
+ */
+ public function get_entry($entry_id)
+ {
+ $sql = 'SELECT e.*, r.reason_title
+ FROM ' . $this->entries_table . ' e
+ LEFT JOIN ' . $this->reasons_table . ' r
+ ON (r.reason_id = e.reason_id)
+ WHERE e.entry_id = ' . (int) $entry_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Give a member a reason
+ *
+ * @param int $user_id
+ * @param int $reason_id
+ * @param bool $current Make it the member's current entry
+ * @return int The entry's ID
+ */
+ public function add_entry($user_id, $reason_id, $current)
+ {
+ $sql_ary = [
+ 'user_id' => (int) $user_id,
+ 'reason_id' => (int) $reason_id,
+ 'entry_time' => time(),
+ 'entry_author_id' => (int) $this->user->data['user_id'],
+ 'entry_author_name' => (string) $this->user->data['username'],
+ 'entry_current' => 0,
+ ];
+
+ $this->db->sql_transaction('begin');
+ $this->db->sql_query('INSERT INTO ' . $this->entries_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+ $entry_id = (int) $this->db->sql_nextid();
+
+ if ($current)
+ {
+ $this->set_current($user_id, $entry_id);
+ }
+ $this->db->sql_transaction('commit');
+
+ return $entry_id;
+ }
+
+ /**
+ * Make one of a member's entries current, or none
+ *
+ * @param int $user_id
+ * @param int $entry_id 0 for none
+ * @return void
+ */
+ public function set_current($user_id, $entry_id)
+ {
+ $this->db->sql_transaction('begin');
+
+ $sql = 'UPDATE ' . $this->entries_table . '
+ SET entry_current = 0
+ WHERE user_id = ' . (int) $user_id;
+ $this->db->sql_query($sql);
+
+ if ($entry_id)
+ {
+ $sql = 'UPDATE ' . $this->entries_table . '
+ SET entry_current = 1
+ WHERE user_id = ' . (int) $user_id . '
+ AND entry_id = ' . (int) $entry_id;
+ $this->db->sql_query($sql);
+ }
+
+ $this->db->sql_transaction('commit');
+
+ unset($this->current_cache[(int) $user_id]);
+ }
+
+ /**
+ * Delete an entry with its notes
+ *
+ * @param int $entry_id
+ * @return void
+ */
+ public function delete_entry($entry_id)
+ {
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows([(int) $entry_id]);
+ $this->db->sql_transaction('commit');
+ }
+
+ /**
+ * The current entry of each member that has one, with its reason and
+ * number of notes; read once per member
+ *
+ * @param array $user_ids
+ * @return array Rows by user ID, members without a current entry left out
+ */
+ public function get_current_for_users(array $user_ids)
+ {
+ $user_ids = array_unique(array_map('intval', $user_ids));
+ $missing = array_values(array_diff($user_ids, array_keys($this->current_cache)));
+
+ if ($missing)
+ {
+ foreach ($missing as $user_id)
+ {
+ $this->current_cache[$user_id] = false;
+ }
+
+ $sql = 'SELECT e.entry_id, e.user_id, r.reason_title, r.reason_description, r.reason_color
+ FROM ' . $this->entries_table . ' e, ' . $this->reasons_table . ' r
+ WHERE r.reason_id = e.reason_id
+ AND e.entry_current = 1
+ AND ' . $this->db->sql_in_set('e.user_id', $missing);
+ $result = $this->db->sql_query($sql);
+
+ $by_entry = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $row['note_count'] = 0;
+ $by_entry[(int) $row['entry_id']] = (int) $row['user_id'];
+ $this->current_cache[(int) $row['user_id']] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ if ($by_entry)
+ {
+ $sql = 'SELECT entry_id, COUNT(note_id) AS note_count
+ FROM ' . $this->notes_table . '
+ WHERE ' . $this->db->sql_in_set('entry_id', array_keys($by_entry)) . '
+ GROUP BY entry_id';
+ $result = $this->db->sql_query($sql);
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $this->current_cache[$by_entry[(int) $row['entry_id']]]['note_count'] = (int) $row['note_count'];
+ }
+ $this->db->sql_freeresult($result);
+ }
+ }
+
+ $current = [];
+ foreach ($user_ids as $user_id)
+ {
+ if ($this->current_cache[$user_id] !== false)
+ {
+ $current[$user_id] = $this->current_cache[$user_id];
+ }
+ }
+
+ return $current;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Notes */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * The notes of some entries, newest first, with their author and editor
+ *
+ * @param array $entry_ids
+ * @return array Lists of rows by entry ID
+ */
+ public function get_notes(array $entry_ids)
+ {
+ if (!$entry_ids)
+ {
+ return [];
+ }
+
+ $sql_ary = [
+ 'SELECT' => 'n.*, a.username AS author_username, a.user_colour AS author_colour, ed.username AS editor_username, ed.user_colour AS editor_colour',
+ 'FROM' => [$this->notes_table => 'n'],
+ 'LEFT_JOIN' => [
+ [
+ 'FROM' => [USERS_TABLE => 'a'],
+ 'ON' => 'a.user_id = n.note_author_id',
+ ],
+ [
+ 'FROM' => [USERS_TABLE => 'ed'],
+ 'ON' => 'ed.user_id = n.note_edit_user_id',
+ ],
+ ],
+ 'WHERE' => $this->db->sql_in_set('n.entry_id', array_map('intval', $entry_ids)),
+ 'ORDER_BY' => 'n.note_time DESC, n.note_id DESC',
+ ];
+ $result = $this->db->sql_query($this->db->sql_build_query('SELECT', $sql_ary));
+
+ $notes = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $notes[(int) $row['entry_id']][] = $row;
+ }
+ $this->db->sql_freeresult($result);
+
+ return $notes;
+ }
+
+ /**
+ * One note, with the member its entry is about
+ *
+ * @param int $note_id
+ * @return array|null
+ */
+ public function get_note($note_id)
+ {
+ $sql = 'SELECT n.*, e.user_id, r.reason_title
+ FROM ' . $this->notes_table . ' n
+ INNER JOIN ' . $this->entries_table . ' e
+ ON (e.entry_id = n.entry_id)
+ LEFT JOIN ' . $this->reasons_table . ' r
+ ON (r.reason_id = e.reason_id)
+ WHERE n.note_id = ' . (int) $note_id;
+ $result = $this->db->sql_query($sql);
+ $row = $this->db->sql_fetchrow($result);
+ $this->db->sql_freeresult($result);
+
+ return $row ?: null;
+ }
+
+ /**
+ * Parse note text with the board's private message settings
+ *
+ * @param string $text Text as typed, from request->variable(..., true)
+ * @return array text, uid, bitfield, options, errors (untranslated keys or messages)
+ */
+ public function parse_note($text)
+ {
+ $uid = $bitfield = '';
+ $options = 0;
+
+ $allow_bbcode = $this->config['allow_bbcode'] && $this->config['auth_bbcode_pm'];
+ $allow_smilies = $this->config['allow_smilies'] && $this->config['auth_smilies_pm'];
+ $allow_urls = (bool) $this->config['allow_post_links'];
+
+ $errors = [];
+ if (trim($text) === '')
+ {
+ $errors[] = 'SEENOTES_NOTE_EMPTY';
+ }
+
+ $allow_img = (bool) $this->config['auth_img_pm'];
+
+ // phpBB 4.0 removed the allow_flash_bbcode argument, so the later
+ // arguments sit one place earlier there
+ if (phpbb_version_compare(PHPBB_VERSION, '4.0.0-a1', '>='))
+ {
+ $warnings = generate_text_for_storage($text, $uid, $bitfield, $options, $allow_bbcode, $allow_urls, $allow_smilies, $allow_img, true, $allow_urls, 'pm');
+ }
+ else
+ {
+ $warnings = generate_text_for_storage($text, $uid, $bitfield, $options, $allow_bbcode, $allow_urls, $allow_smilies, $allow_img, (bool) $this->config['auth_flash_pm'], true, $allow_urls, 'pm');
+ }
+
+ return [
+ 'text' => $text,
+ 'uid' => $uid,
+ 'bitfield' => $bitfield,
+ 'options' => (int) $options,
+ 'errors' => array_merge($errors, (array) $warnings),
+ ];
+ }
+
+ /**
+ * The BBCode, smilies and links settings notes are written with
+ *
+ * @return array bbcode, smilies, urls, img, flash, quote
+ */
+ public function get_editor_settings()
+ {
+ $bbcode = $this->config['allow_bbcode'] && $this->config['auth_bbcode_pm'];
+
+ return [
+ 'bbcode' => (bool) $bbcode,
+ 'smilies' => $this->config['allow_smilies'] && $this->config['auth_smilies_pm'],
+ 'urls' => (bool) $this->config['allow_post_links'],
+ 'img' => $bbcode && $this->config['auth_img_pm'],
+ 'flash' => $bbcode && $this->config['auth_flash_pm'],
+ 'quote' => (bool) $bbcode,
+ ];
+ }
+
+ /**
+ * Add a note under an entry
+ *
+ * @param int $entry_id
+ * @param array $parsed From parse_note()
+ * @return int The note's ID
+ */
+ public function add_note($entry_id, array $parsed)
+ {
+ $sql_ary = [
+ 'entry_id' => (int) $entry_id,
+ 'note_author_id' => (int) $this->user->data['user_id'],
+ 'note_author_name' => (string) $this->user->data['username'],
+ 'note_time' => time(),
+ 'note_text' => $parsed['text'],
+ 'note_bbcode_uid' => $parsed['uid'],
+ 'note_bbcode_bitfield' => $parsed['bitfield'],
+ 'note_bbcode_options' => (int) $parsed['options'],
+ ];
+
+ $this->db->sql_query('INSERT INTO ' . $this->notes_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary));
+ $this->current_cache = [];
+
+ return (int) $this->db->sql_nextid();
+ }
+
+ /**
+ * Change a note's text, recording who edited it and when
+ *
+ * @param int $note_id
+ * @param array $parsed From parse_note()
+ * @return void
+ */
+ public function edit_note($note_id, array $parsed)
+ {
+ $sql_ary = [
+ 'note_text' => $parsed['text'],
+ 'note_bbcode_uid' => $parsed['uid'],
+ 'note_bbcode_bitfield' => $parsed['bitfield'],
+ 'note_bbcode_options' => (int) $parsed['options'],
+ 'note_edit_time' => time(),
+ 'note_edit_user_id' => (int) $this->user->data['user_id'],
+ 'note_edit_user_name' => (string) $this->user->data['username'],
+ ];
+
+ $sql = 'UPDATE ' . $this->notes_table . '
+ SET ' . $this->db->sql_build_array('UPDATE', $sql_ary) . '
+ WHERE note_id = ' . (int) $note_id;
+ $this->db->sql_query($sql);
+ }
+
+ /**
+ * Delete a note
+ *
+ * @param int $note_id
+ * @return void
+ */
+ public function delete_note($note_id)
+ {
+ $this->db->sql_query('DELETE FROM ' . $this->notes_table . ' WHERE note_id = ' . (int) $note_id);
+ $this->current_cache = [];
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Members */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Delete the entries and notes about deleted members
+ *
+ * @param array $user_ids
+ * @return void
+ */
+ public function delete_users(array $user_ids)
+ {
+ if (!$user_ids)
+ {
+ return;
+ }
+
+ $entry_ids = $this->get_entry_ids($this->db->sql_in_set('user_id', array_map('intval', $user_ids)));
+
+ $this->db->sql_transaction('begin');
+ $this->delete_entry_rows($entry_ids);
+ $this->db->sql_transaction('commit');
+ }
+
+ /**
+ * Keep the saved names of a renamed member up to date
+ *
+ * Only rows written by the renamed member change, so a deleted member who
+ * had the same name keeps it.
+ *
+ * @param string $old_name
+ * @param string $new_name
+ * @return void
+ */
+ public function update_username($old_name, $new_name)
+ {
+ $sql = 'SELECT user_id
+ FROM ' . USERS_TABLE . "
+ WHERE username_clean = '" . $this->db->sql_escape(utf8_clean_string($new_name)) . "'";
+ $result = $this->db->sql_query($sql);
+ $user_id = (int) $this->db->sql_fetchfield('user_id');
+ $this->db->sql_freeresult($result);
+
+ if (!$user_id)
+ {
+ return;
+ }
+
+ $updates = [
+ [$this->entries_table, 'entry_author_name', 'entry_author_id'],
+ [$this->notes_table, 'note_author_name', 'note_author_id'],
+ [$this->notes_table, 'note_edit_user_name', 'note_edit_user_id'],
+ ];
+
+ foreach ($updates as $update)
+ {
+ list($table, $name_column, $id_column) = $update;
+
+ $sql = 'UPDATE ' . $table . "
+ SET $name_column = '" . $this->db->sql_escape($new_name) . "'
+ WHERE $id_column = " . $user_id . "
+ AND $name_column = '" . $this->db->sql_escape($old_name) . "'";
+ $this->db->sql_query($sql);
+ }
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Logs */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Add an entry to the moderator log
+ *
+ * @param string $operation Language key
+ * @param array $data Values for the language key
+ * @return void
+ */
+ public function log_moderator($operation, array $data)
+ {
+ $this->log->add('mod', (int) $this->user->data['user_id'], $this->user->ip, $operation, false, array_merge(['forum_id' => 0, 'topic_id' => 0], array_values($data)));
+ }
+
+ /**
+ * Add an entry to the admin log
+ *
+ * @param string $operation Language key
+ * @param array $data Values for the language key
+ * @return void
+ */
+ public function log_admin($operation, array $data = [])
+ {
+ $this->log->add('admin', (int) $this->user->data['user_id'], $this->user->ip, $operation, false, array_values($data));
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Helpers */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Entry IDs matching a condition
+ *
+ * @param string $where SQL condition on the entries table
+ * @return array
+ */
+ protected function get_entry_ids($where)
+ {
+ $sql = 'SELECT entry_id
+ FROM ' . $this->entries_table . '
+ WHERE ' . $where;
+ $result = $this->db->sql_query($sql);
+
+ $entry_ids = [];
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $entry_ids[] = (int) $row['entry_id'];
+ }
+ $this->db->sql_freeresult($result);
+
+ return $entry_ids;
+ }
+
+ /**
+ * Delete entries and their notes; the caller holds the transaction
+ *
+ * @param array $entry_ids
+ * @return void
+ */
+ protected function delete_entry_rows(array $entry_ids)
+ {
+ if (!$entry_ids)
+ {
+ return;
+ }
+
+ $this->db->sql_query('DELETE FROM ' . $this->notes_table . ' WHERE ' . $this->db->sql_in_set('entry_id', $entry_ids));
+ $this->db->sql_query('DELETE FROM ' . $this->entries_table . ' WHERE ' . $this->db->sql_in_set('entry_id', $entry_ids));
+ $this->current_cache = [];
+ }
+}
diff --git a/mcp/main_info.php b/mcp/main_info.php
new file mode 100644
index 0000000..bfdbf4f
--- /dev/null
+++ b/mcp/main_info.php
@@ -0,0 +1,32 @@
+ '\phpbbmodders\seenotes\mcp\main_module',
+ 'title' => 'MCP_SEENOTES',
+ 'modes' => [
+ 'notes' => [
+ 'title' => 'MCP_SEENOTES_NOTES',
+ 'auth' => 'ext_phpbbmodders/seenotes && (aclf_m_seenotes_view || acl_a_seenotes_view)',
+ 'cat' => ['MCP_SEENOTES'],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/mcp/main_module.php b/mcp/main_module.php
new file mode 100644
index 0000000..c733374
--- /dev/null
+++ b/mcp/main_module.php
@@ -0,0 +1,67 @@
+add_lang('seenotes', 'phpbbmodders/seenotes');
+ $this->tpl_name = '@phpbbmodders_seenotes/mcp_seenotes';
+ $this->page_title = 'MCP_SEENOTES_NOTES';
+
+ $user_id = $request->variable('u', 0);
+ $username = $request->variable('username', '', true);
+
+ if (!$user_id && $username === '')
+ {
+ $template->assign_vars([
+ 'S_SEENOTES_FRONT' => true,
+ 'U_SEENOTES_FIND' => $this->u_action,
+ 'U_FIND_USERNAME' => append_sid("{$phpbb_root_path}memberlist.$phpEx", 'mode=searchuser&form=mcp&field=username&select_single=true'),
+ ]);
+
+ return;
+ }
+
+ $where = $user_id ? 'user_id = ' . (int) $user_id : "username_clean = '" . $db->sql_escape(utf8_clean_string($username)) . "'";
+ $sql = 'SELECT user_id, username, user_colour
+ FROM ' . USERS_TABLE . '
+ WHERE ' . $where . '
+ AND user_id <> ' . ANONYMOUS;
+ $result = $db->sql_query($sql);
+ $member = $db->sql_fetchrow($result);
+ $db->sql_freeresult($result);
+
+ if (!$member)
+ {
+ trigger_error($language->lang('NO_USER') . ' ' . $language->lang('RETURN_PAGE', '', ' '), E_USER_WARNING);
+ }
+
+ /** @var \phpbbmodders\seenotes\notes_page $page */
+ $page = $phpbb_container->get('phpbbmodders.seenotes.notes_page');
+ $page->handle($member, $this->u_action . '&u=' . (int) $member['user_id'], false);
+ }
+}
diff --git a/migrations/v10x/default_reasons.php b/migrations/v10x/default_reasons.php
new file mode 100644
index 0000000..998ca8d
--- /dev/null
+++ b/migrations/v10x/default_reasons.php
@@ -0,0 +1,125 @@
+/install_seenotes.php.
+ */
+ const REASONS = [
+ 'SEENOTES_DEFAULT_QUEUE' => 'AA0000',
+ 'SEENOTES_DEFAULT_SPAM' => 'A04800',
+ 'SEENOTES_DEFAULT_WAREZ' => '7B0000',
+ 'SEENOTES_DEFAULT_CONDUCT' => '8C5A00',
+ 'SEENOTES_DEFAULT_RETURNING'=> '6B2C91',
+ 'SEENOTES_DEFAULT_CONTACT' => '0055AA',
+ 'SEENOTES_DEFAULT_GENERAL' => '555555',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ public function effectively_installed()
+ {
+ $sql = 'SELECT reason_id
+ FROM ' . $this->table_prefix . 'seenotes_reasons';
+ $result = $this->db->sql_query_limit($sql, 1);
+ $has_reasons = (bool) $this->db->sql_fetchfield('reason_id');
+ $this->db->sql_freeresult($result);
+
+ return $has_reasons;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function depends_on()
+ {
+ return ['\phpbbmodders\seenotes\migrations\v10x\install'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function update_data()
+ {
+ return [
+ ['custom', [[$this, 'add_default_reasons']]],
+ ];
+ }
+
+ /**
+ * Insert the starter reasons, in the board's default language when the
+ * extension ships it, otherwise in English
+ *
+ * @return void
+ */
+ public function add_default_reasons()
+ {
+ $strings = $this->load_strings((string) $this->config['default_lang']);
+
+ $rows = [];
+ $order = 0;
+ foreach (self::REASONS as $key => $color)
+ {
+ if (!isset($strings[$key]))
+ {
+ continue;
+ }
+
+ // Stored escaped, like reasons saved from the ACP form
+ $rows[] = [
+ 'reason_title' => utf8_htmlspecialchars($strings[$key]),
+ 'reason_description' => isset($strings[$key . '_EXPLAIN']) ? utf8_htmlspecialchars($strings[$key . '_EXPLAIN']) : '',
+ 'reason_color' => $color,
+ 'reason_order' => ++$order,
+ 'reason_active' => 1,
+ ];
+ }
+
+ if ($rows)
+ {
+ $this->db->sql_multi_insert($this->table_prefix . 'seenotes_reasons', $rows);
+ }
+ }
+
+ /**
+ * The extension's install strings in a language, falling back to English
+ *
+ * @param string $lang_iso Language folder name, such as "en" or "de"
+ * @return array Language keys and their text
+ */
+ protected function load_strings($lang_iso)
+ {
+ $base = $this->phpbb_root_path . 'ext/phpbbmodders/seenotes/language/';
+ $file = $base . 'en/install_seenotes.' . $this->php_ext;
+
+ // Only a plain folder name, so the setting cannot point elsewhere
+ if (preg_match('/\A[a-z0-9_-]+\z/i', $lang_iso) && is_file($base . $lang_iso . '/install_seenotes.' . $this->php_ext))
+ {
+ $file = $base . $lang_iso . '/install_seenotes.' . $this->php_ext;
+ }
+
+ $lang = [];
+ include($file);
+
+ return $lang;
+ }
+}
diff --git a/migrations/v10x/install.php b/migrations/v10x/install.php
new file mode 100644
index 0000000..ce7ac32
--- /dev/null
+++ b/migrations/v10x/install.php
@@ -0,0 +1,208 @@
+db_tools->sql_table_exists($this->table_prefix . 'seenotes_reasons');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function depends_on()
+ {
+ return ['\phpbb\db\migration\data\v330\v330'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function update_schema()
+ {
+ return [
+ 'add_tables' => [
+ // The reasons administrators set up
+ $this->table_prefix . 'seenotes_reasons' => [
+ 'COLUMNS' => [
+ 'reason_id' => ['UINT', null, 'auto_increment'],
+ 'reason_title' => ['VCHAR_UNI', ''],
+ 'reason_description' => ['VCHAR_UNI', ''],
+ 'reason_color' => ['VCHAR:6', 'AA0000'],
+ 'reason_order' => ['UINT', 0],
+ 'reason_active' => ['BOOL', 1],
+ ],
+ 'PRIMARY_KEY' => 'reason_id',
+ 'KEYS' => [
+ 'reason_order' => ['INDEX', 'reason_order'],
+ ],
+ ],
+ // A reason given to a member; at most one per member is current
+ $this->table_prefix . 'seenotes_entries' => [
+ 'COLUMNS' => [
+ 'entry_id' => ['UINT', null, 'auto_increment'],
+ 'user_id' => ['UINT', 0],
+ 'reason_id' => ['UINT', 0],
+ 'entry_time' => ['TIMESTAMP', 0],
+ 'entry_author_id' => ['UINT', 0],
+ 'entry_author_name' => ['VCHAR_UNI', ''],
+ 'entry_current' => ['BOOL', 0],
+ ],
+ 'PRIMARY_KEY' => 'entry_id',
+ 'KEYS' => [
+ 'user_id' => ['INDEX', 'user_id'],
+ 'reason_id' => ['INDEX', 'reason_id'],
+ 'user_current' => ['INDEX', ['user_id', 'entry_current']],
+ ],
+ ],
+ // Notes under an entry, stored the way phpBB stores private messages
+ $this->table_prefix . 'seenotes_notes' => [
+ 'COLUMNS' => [
+ 'note_id' => ['UINT', null, 'auto_increment'],
+ 'entry_id' => ['UINT', 0],
+ 'note_author_id' => ['UINT', 0],
+ 'note_author_name' => ['VCHAR_UNI', ''],
+ 'note_time' => ['TIMESTAMP', 0],
+ 'note_text' => ['MTEXT_UNI', ''],
+ 'note_bbcode_uid' => ['VCHAR:8', ''],
+ 'note_bbcode_bitfield' => ['VCHAR:255', ''],
+ 'note_bbcode_options' => ['UINT:11', 7],
+ 'note_edit_time' => ['TIMESTAMP', 0],
+ 'note_edit_user_id' => ['UINT', 0],
+ 'note_edit_user_name' => ['VCHAR_UNI', ''],
+ ],
+ 'PRIMARY_KEY' => 'note_id',
+ 'KEYS' => [
+ 'entry_id' => ['INDEX', 'entry_id'],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function revert_schema()
+ {
+ return [
+ 'drop_tables' => [
+ $this->table_prefix . 'seenotes_notes',
+ $this->table_prefix . 'seenotes_entries',
+ $this->table_prefix . 'seenotes_reasons',
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function update_data()
+ {
+ $data = [
+ // The label shown before the reason
+ ['config.add', ['seenotes_label', 'SEE NOTES']],
+ // Reasons must have a description (0 no, 1 yes)
+ ['config.add', ['seenotes_require_description', 0]],
+ // Reasons can be deleted with all their entries (0 no, 1 yes)
+ ['config.add', ['seenotes_allow_hard_delete', 0]],
+ ];
+
+ // Moderator permissions, global and per forum like phpBB's own; having
+ // one in any forum is enough
+ $moderator_permissions = [
+ 'm_seenotes_view' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_manage' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_note_delete' => ['ROLE_MOD_STANDARD', 'ROLE_MOD_FULL'],
+ 'm_seenotes_note_edit' => ['ROLE_MOD_FULL'],
+ 'm_seenotes_entry_delete' => ['ROLE_MOD_FULL'],
+ ];
+
+ foreach ($moderator_permissions as $permission => $roles)
+ {
+ $data[] = ['permission.add', [$permission, true]];
+ $data[] = ['permission.add', [$permission, false]];
+ $data = array_merge($data, $this->set_in_roles($permission, $roles));
+ }
+
+ // Matching administrator permissions, since the Administrators group
+ // has no moderator role by default
+ $admin_permissions = [
+ 'a_seenotes_view',
+ 'a_seenotes_manage',
+ 'a_seenotes_note_delete',
+ 'a_seenotes_note_edit',
+ 'a_seenotes_entry_delete',
+ 'a_seenotes_settings',
+ ];
+
+ foreach ($admin_permissions as $permission)
+ {
+ $data[] = ['permission.add', [$permission, true]];
+ $data = array_merge($data, $this->set_in_roles($permission, ['ROLE_ADMIN_STANDARD', 'ROLE_ADMIN_FULL']));
+ }
+
+ return array_merge($data, [
+ ['module.add', ['acp', 'ACP_CAT_DOT_MODS', 'ACP_SEENOTES']],
+ ['module.add', ['acp', 'ACP_SEENOTES', [
+ 'module_basename' => '\phpbbmodders\seenotes\acp\main_module',
+ 'modes' => ['reasons', 'settings'],
+ ]]],
+
+ // A page in ACP > Manage users' form list, handled through
+ // core.acp_users_mode_add
+ ['module.add', ['acp', 'ACP_CAT_USERS', [
+ 'module_basename' => 'acp_users',
+ 'module_langname' => 'ACP_USER_SEENOTES',
+ 'module_mode' => 'seenotes',
+ 'module_auth' => 'ext_phpbbmodders/seenotes && acl_a_user && acl_a_seenotes_view',
+ 'module_display' => 0,
+ ]]],
+
+ ['module.add', ['mcp', 0, 'MCP_SEENOTES']],
+ ['module.add', ['mcp', 'MCP_SEENOTES', [
+ 'module_basename' => '\phpbbmodders\seenotes\mcp\main_module',
+ 'modes' => ['notes'],
+ ]]],
+ ]);
+ }
+
+ /**
+ * Steps that give a permission to roles that exist on this board
+ *
+ * @param string $permission
+ * @param array $roles
+ * @return array
+ */
+ protected function set_in_roles($permission, array $roles)
+ {
+ $steps = [];
+
+ foreach ($roles as $role)
+ {
+ $steps[] = ['if', [
+ ['permission.role_exists', [$role]],
+ ['permission.permission_set', [$role, $permission]],
+ ]];
+ }
+
+ return $steps;
+ }
+}
diff --git a/notes_page.php b/notes_page.php
new file mode 100644
index 0000000..5c61956
--- /dev/null
+++ b/notes_page.php
@@ -0,0 +1,642 @@
+language = $language;
+ $this->log = $log;
+ $this->request = $request;
+ $this->template = $template;
+ $this->user = $user;
+ $this->manager = $manager;
+ $this->root_path = $root_path;
+ $this->php_ext = $php_ext;
+ }
+
+ /**
+ * Run the requested action, then show the member's entries and notes
+ *
+ * @param array $member Users table row of the member
+ * @param string $u_action Page URL, including the member's ID
+ * @param bool $in_acp Shown in the ACP rather than the MCP
+ * @return void
+ */
+ public function handle(array $member, $u_action, $in_acp)
+ {
+ $this->member = $member;
+ $this->u_action = $u_action;
+ $this->in_acp = $in_acp;
+
+ // acp/common holds the phpBB user-note log strings, as on core's own notes page
+ $this->language->add_lang(['posting', 'mcp', 'acp/common']);
+ $this->language->add_lang('seenotes', 'phpbbmodders/seenotes');
+
+ if (!$this->manager->can('view'))
+ {
+ $this->error('NOT_AUTHORISED');
+ }
+
+ add_form_key(self::FORM_KEY);
+
+ $errors = [];
+ $editor_text = null;
+ $action = $this->request->variable('action', '');
+
+ switch ($action)
+ {
+ case 'add_entry':
+ $this->add_entry();
+ break;
+
+ case 'set_current':
+ $this->set_current();
+ break;
+
+ case 'delete_entry':
+ $this->delete_entry();
+ break;
+
+ case 'add_note':
+ case 'edit_note':
+ $editor_text = $this->save_note($action === 'edit_note', $errors);
+ break;
+
+ case 'delete_note':
+ $this->delete_note();
+ break;
+ }
+
+ $this->display($errors, $editor_text);
+ }
+
+ /**
+ * Give the member a reason
+ *
+ * @return void
+ */
+ protected function add_entry()
+ {
+ $this->require_post_permission('manage');
+
+ $reason_id = $this->request->variable('reason_id', 0);
+ $reasons = $this->manager->get_reasons(true);
+ if (!isset($reasons[$reason_id]))
+ {
+ $this->error('SEENOTES_REASON_NOT_SELECTED');
+ }
+
+ $current = (bool) $this->request->variable('entry_current', false);
+ $this->manager->add_entry((int) $this->member['user_id'], $reason_id, $current);
+ $this->manager->log_moderator('LOG_SEENOTES_ENTRY_ADDED', [$reasons[$reason_id]['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_ENTRY_ADDED');
+ }
+
+ /**
+ * Make one of the member's entries current, or none
+ *
+ * @return void
+ */
+ protected function set_current()
+ {
+ $this->require_post_permission('manage');
+
+ $entry_id = $this->request->variable('entry_id', 0);
+ $entry = null;
+ if ($entry_id)
+ {
+ $entry = $this->get_member_entry($entry_id);
+ }
+
+ $this->manager->set_current((int) $this->member['user_id'], $entry_id);
+
+ if ($entry)
+ {
+ $this->manager->log_moderator('LOG_SEENOTES_CURRENT_SET', [$entry['reason_title'], $this->member['username']]);
+ }
+ else
+ {
+ $this->manager->log_moderator('LOG_SEENOTES_CURRENT_CLEARED', [$this->member['username']]);
+ }
+
+ $this->success($entry ? 'SEENOTES_CURRENT_SET' : 'SEENOTES_CURRENT_CLEARED');
+ }
+
+ /**
+ * Delete one of the member's entries with its notes, after confirming
+ *
+ * @return void
+ */
+ protected function delete_entry()
+ {
+ $this->require_permission('entry_delete');
+
+ $entry_id = $this->request->variable('entry_id', 0);
+ $entry = $this->get_member_entry($entry_id);
+
+ if (confirm_box(true))
+ {
+ $this->manager->delete_entry($entry_id);
+ $this->manager->log_moderator('LOG_SEENOTES_ENTRY_DELETED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_ENTRY_DELETED');
+ }
+
+ confirm_box(false, $this->language->lang('SEENOTES_DELETE_ENTRY_CONFIRM', $entry['reason_title']), build_hidden_fields([
+ 'u' => (int) $this->member['user_id'],
+ 'action' => 'delete_entry',
+ 'entry_id' => $entry_id,
+ ]));
+
+ redirect($this->u_action_raw());
+ }
+
+ /**
+ * Add a note under one of the member's entries, or change a note
+ *
+ * @param bool $edit Change an existing note
+ * @param array $errors Filled with errors to show above the form
+ * @return string|null Text to put back in the form, null to use the default
+ */
+ protected function save_note($edit, array &$errors)
+ {
+ $this->require_permission($edit ? 'note_edit' : 'manage');
+
+ $note = null;
+ if ($edit)
+ {
+ $note = $this->get_member_note($this->request->variable('note_id', 0));
+ }
+
+ if (!$this->request->is_set_post('submit_note'))
+ {
+ // Opening the form to edit a note
+ return null;
+ }
+
+ if (!check_form_key(self::FORM_KEY))
+ {
+ $this->error('FORM_INVALID');
+ }
+
+ $text = $this->request->variable('message', '', true);
+
+ if ($edit)
+ {
+ $entry = ['reason_title' => $note['reason_title']];
+ }
+ else
+ {
+ $entry = $this->get_member_entry($this->request->variable('entry_id', 0));
+ }
+
+ $parsed = $this->manager->parse_note($text);
+ if ($parsed['errors'])
+ {
+ $errors = array_map([$this->language, 'lang'], $parsed['errors']);
+
+ return $text;
+ }
+
+ if ($edit)
+ {
+ $this->manager->edit_note((int) $note['note_id'], $parsed);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_EDITED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_EDITED');
+ }
+
+ $this->manager->add_note((int) $entry['entry_id'], $parsed);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_ADDED', [$entry['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_ADDED');
+ }
+
+ /**
+ * Delete one of the member's notes, after confirming
+ *
+ * @return void
+ */
+ protected function delete_note()
+ {
+ $this->require_permission('note_delete');
+
+ $note_id = $this->request->variable('note_id', 0);
+ $note = $this->get_member_note($note_id);
+
+ if (confirm_box(true))
+ {
+ $this->manager->delete_note($note_id);
+ $this->manager->log_moderator('LOG_SEENOTES_NOTE_DELETED', [$note['reason_title'], $this->member['username']]);
+
+ $this->success('SEENOTES_NOTE_DELETED');
+ }
+
+ confirm_box(false, $this->language->lang('SEENOTES_DELETE_NOTE_CONFIRM'), build_hidden_fields([
+ 'u' => (int) $this->member['user_id'],
+ 'action' => 'delete_note',
+ 'note_id' => $note_id,
+ ]));
+
+ redirect($this->u_action_raw());
+ }
+
+ /**
+ * Show the member's entries and notes, the forms, and the latest phpBB
+ * user notes
+ *
+ * @param array $errors Errors to show above the note form
+ * @param string|null $editor_text Text to put back in the note form
+ * @return void
+ */
+ protected function display(array $errors, $editor_text)
+ {
+ $user_id = (int) $this->member['user_id'];
+ $entries = $this->manager->get_entries($user_id);
+ $notes = $this->manager->get_notes(array_keys($entries));
+
+ $can_manage = $this->manager->can('manage');
+ $can_edit = $this->manager->can('note_edit');
+ $can_delete_note = $this->manager->can('note_delete');
+ $can_delete_entry = $this->manager->can('entry_delete');
+
+ $current_entry_id = 0;
+ foreach ($entries as $entry_id => $entry)
+ {
+ $entry_notes = isset($notes[$entry_id]) ? $notes[$entry_id] : [];
+ if ($entry['entry_current'])
+ {
+ $current_entry_id = $entry_id;
+ }
+
+ $this->template->assign_block_vars('seenotes_entries', [
+ 'ID' => $entry_id,
+ 'REASON' => $entry['reason_title'],
+ 'DESCRIPTION' => $entry['reason_description'],
+ 'COLOR' => $this->manager->clean_color($entry['reason_color']),
+ 'AUTHOR' => $this->username($entry['entry_author_id'], $entry['username'], $entry['user_colour'], $entry['entry_author_name']),
+ 'TIME' => $this->user->format_date($entry['entry_time']),
+ 'NOTE_COUNT' => $this->language->lang('SEENOTES_NOTE_COUNT', count($entry_notes)),
+ 'S_CURRENT' => (bool) $entry['entry_current'],
+ 'S_ACTIVE' => (bool) $entry['reason_active'],
+ 'U_ADD_NOTE' => $this->u_action . '&entry_id=' . $entry_id . '#seenotes-editor',
+ 'U_DELETE' => $this->u_action . '&action=delete_entry&entry_id=' . $entry_id,
+ ]);
+
+ foreach ($entry_notes as $note)
+ {
+ $this->template->assign_block_vars('seenotes_entries.notes', [
+ 'ID' => (int) $note['note_id'],
+ 'TEXT' => generate_text_for_display($note['note_text'], $note['note_bbcode_uid'], $note['note_bbcode_bitfield'], (int) $note['note_bbcode_options']),
+ 'AUTHOR' => $this->username($note['note_author_id'], $note['author_username'], $note['author_colour'], $note['note_author_name']),
+ 'TIME' => $this->user->format_date($note['note_time']),
+ 'EDITED' => $note['note_edit_time'] ? $this->language->lang(
+ 'SEENOTES_NOTE_EDITED_BY',
+ $this->username($note['note_edit_user_id'], $note['editor_username'], $note['editor_colour'], $note['note_edit_user_name']),
+ $this->user->format_date($note['note_edit_time'])
+ ) : '',
+ 'U_EDIT' => $this->u_action . '&action=edit_note¬e_id=' . (int) $note['note_id'] . '#seenotes-editor',
+ 'U_DELETE' => $this->u_action . '&action=delete_note¬e_id=' . (int) $note['note_id'],
+ ]);
+ }
+ }
+
+ $this->assign_editor($entries, $current_entry_id, $editor_text, $can_manage, $can_edit);
+
+ if ($can_manage)
+ {
+ foreach ($this->manager->get_reasons(true) as $reason_id => $reason)
+ {
+ $this->template->assign_block_vars('seenotes_reasons', [
+ 'ID' => $reason_id,
+ 'TITLE' => $reason['reason_title'],
+ 'DESCRIPTION' => $reason['reason_description'],
+ ]);
+ }
+ }
+
+ $this->assign_user_notes($user_id);
+
+ $this->template->assign_vars([
+ 'S_SEENOTES_PAGE' => true,
+ 'SEENOTES_MEMBER' => get_username_string('full', $user_id, $this->member['username'], $this->member['user_colour']),
+ 'SEENOTES_ERROR' => implode(' ', $errors),
+ 'S_SEENOTES_CAN_MANAGE' => $can_manage,
+ 'S_SEENOTES_CAN_EDIT' => $can_edit,
+ 'S_SEENOTES_CAN_DELETE_NOTE' => $can_delete_note,
+ 'S_SEENOTES_CAN_DELETE_ENTRY' => $can_delete_entry,
+ 'S_SEENOTES_HAS_CURRENT' => $current_entry_id > 0,
+ 'U_SEENOTES_ACTION' => $this->u_action,
+ 'S_SEENOTES_HIDDEN_USER' => build_hidden_fields(['u' => $user_id]),
+ ]);
+ }
+
+ /**
+ * Fill the note form: a new note under a chosen entry, or a note being
+ * edited
+ *
+ * @param array $entries The member's entries
+ * @param int $current_entry_id
+ * @param string|null $editor_text Text sent back after an error
+ * @param bool $can_manage
+ * @param bool $can_edit
+ * @return void
+ */
+ protected function assign_editor(array $entries, $current_entry_id, $editor_text, $can_manage, $can_edit)
+ {
+ $edit_note = null;
+ if ($this->request->variable('action', '') === 'edit_note' && $can_edit)
+ {
+ $edit_note = $this->get_member_note($this->request->variable('note_id', 0));
+ }
+
+ if (!$entries || (!$edit_note && !$can_manage))
+ {
+ return;
+ }
+
+ if ($edit_note)
+ {
+ $text = $editor_text !== null ? $editor_text : generate_text_for_edit($edit_note['note_text'], $edit_note['note_bbcode_uid'], (int) $edit_note['note_bbcode_options'])['text'];
+ $selected = (int) $edit_note['entry_id'];
+ }
+ else
+ {
+ $text = $editor_text !== null ? $editor_text : '';
+ $selected = $this->request->variable('entry_id', 0);
+ if (!isset($entries[$selected]))
+ {
+ $selected = $current_entry_id ?: (int) key($entries);
+ }
+
+ foreach ($entries as $entry_id => $entry)
+ {
+ $this->template->assign_block_vars('seenotes_editor_entries', [
+ 'ID' => $entry_id,
+ 'TITLE' => $this->language->lang('SEENOTES_ENTRY_OPTION', $entry['reason_title'], $this->user->format_date($entry['entry_time'], false, true)),
+ 'S_SELECTED'=> $entry_id === $selected,
+ ]);
+ }
+ }
+
+ $settings = $this->manager->get_editor_settings();
+ if ($settings['bbcode'])
+ {
+ if (!function_exists('display_custom_bbcodes'))
+ {
+ include($this->root_path . 'includes/functions_display.' . $this->php_ext);
+ }
+ display_custom_bbcodes();
+ }
+
+ $this->template->assign_vars([
+ 'S_SEENOTES_EDITOR' => true,
+ 'S_SEENOTES_EDITING' => $edit_note !== null,
+ 'SEENOTES_EDIT_NOTE_ID' => $edit_note ? (int) $edit_note['note_id'] : 0,
+ 'SEENOTES_EDITOR_TEXT' => $text,
+ 'S_BBCODE_ALLOWED' => $settings['bbcode'],
+ 'S_BBCODE_QUOTE' => $settings['quote'],
+ 'S_BBCODE_IMG' => $settings['img'],
+ 'S_BBCODE_FLASH' => $settings['flash'],
+ 'S_LINKS_ALLOWED' => $settings['urls'],
+ 'SEENOTES_FORMAT_STATUS'=> $this->language->lang('SEENOTES_FORMAT_STATUS',
+ $this->language->lang($settings['bbcode'] ? 'SEENOTES_ON' : 'SEENOTES_OFF'),
+ $this->language->lang($settings['smilies'] ? 'SEENOTES_ON' : 'SEENOTES_OFF'),
+ $this->language->lang($settings['urls'] ? 'SEENOTES_ON' : 'SEENOTES_OFF')
+ ),
+ ]);
+ }
+
+ /**
+ * The member's latest phpBB user notes, read-only
+ *
+ * @param int $user_id
+ * @return void
+ */
+ protected function assign_user_notes($user_id)
+ {
+ $rows = $this->log->get_logs('user', true, self::USER_NOTES_LIMIT, 0, 0, 0, $user_id);
+ $total = (int) $this->log->get_log_count();
+
+ foreach ($rows as $row)
+ {
+ $this->template->assign_block_vars('seenotes_usernotes', [
+ 'REPORT_BY' => $row['username_full'],
+ 'REPORT_AT' => $this->user->format_date($row['time']),
+ 'ACTION' => $row['action'],
+ ]);
+ }
+
+ if ($this->in_acp)
+ {
+ $u_user_notes = append_sid("{$this->root_path}adm/index.{$this->php_ext}", 'i=users&mode=feedback&u=' . $user_id);
+ }
+ else
+ {
+ $u_user_notes = append_sid("{$this->root_path}mcp.{$this->php_ext}", 'i=notes&mode=user_notes&u=' . $user_id);
+ }
+
+ $this->template->assign_vars([
+ 'SEENOTES_USERNOTES_TOTAL' => $this->language->lang('SEENOTES_USERNOTES_TOTAL', $total),
+ 'U_SEENOTES_USERNOTES' => $u_user_notes,
+ ]);
+ }
+
+ /**
+ * A username that links to the profile while the member exists, or the
+ * saved name as plain text after they were deleted
+ *
+ * @param int $user_id
+ * @param string|null $username Current username, null when deleted
+ * @param string|null $colour
+ * @param string $saved_name Name saved with the record (escaped)
+ * @return string
+ */
+ protected function username($user_id, $username, $colour, $saved_name)
+ {
+ if ($username === null || $username === '')
+ {
+ return $saved_name;
+ }
+
+ return get_username_string('full', (int) $user_id, $username, (string) $colour);
+ }
+
+ /**
+ * An entry of this member, or an error
+ *
+ * @param int $entry_id
+ * @return array
+ */
+ protected function get_member_entry($entry_id)
+ {
+ $entry = $entry_id ? $this->manager->get_entry($entry_id) : null;
+ if (!$entry || (int) $entry['user_id'] !== (int) $this->member['user_id'])
+ {
+ $this->error('SEENOTES_ENTRY_NOT_FOUND');
+ }
+
+ return $entry;
+ }
+
+ /**
+ * A note under one of this member's entries, or an error
+ *
+ * @param int $note_id
+ * @return array
+ */
+ protected function get_member_note($note_id)
+ {
+ $note = $note_id ? $this->manager->get_note($note_id) : null;
+ if (!$note || (int) $note['user_id'] !== (int) $this->member['user_id'])
+ {
+ $this->error('SEENOTES_NOTE_NOT_FOUND');
+ }
+
+ return $note;
+ }
+
+ /**
+ * Stop unless the current user has the permission
+ *
+ * @param string $action
+ * @return void
+ */
+ protected function require_permission($action)
+ {
+ if (!$this->manager->can($action))
+ {
+ $this->error('NOT_AUTHORISED');
+ }
+ }
+
+ /**
+ * Stop unless the request is a valid form post and the current user has
+ * the permission
+ *
+ * @param string $action
+ * @return void
+ */
+ protected function require_post_permission($action)
+ {
+ $this->require_permission($action);
+
+ if (!$this->request->is_set_post('action') || !check_form_key(self::FORM_KEY))
+ {
+ $this->error('FORM_INVALID');
+ }
+ }
+
+ /**
+ * The page URL without HTML-escaped ampersands, for redirects
+ *
+ * @return string
+ */
+ protected function u_action_raw()
+ {
+ return str_replace('&', '&', $this->u_action);
+ }
+
+ /**
+ * Show a success message with a link back to the page
+ *
+ * @param string $message Language key
+ * @return void
+ */
+ protected function success($message)
+ {
+ if ($this->in_acp)
+ {
+ trigger_error($this->language->lang($message) . adm_back_link($this->u_action));
+ }
+
+ meta_refresh(3, $this->u_action);
+ trigger_error($this->language->lang($message) . ' ' . $this->language->lang('RETURN_PAGE', '', ' '));
+ }
+
+ /**
+ * Show an error with a link back to the page
+ *
+ * @param string $message Language key
+ * @return void
+ */
+ protected function error($message)
+ {
+ if ($this->in_acp)
+ {
+ trigger_error($this->language->lang($message) . adm_back_link($this->u_action), E_USER_WARNING);
+ }
+
+ trigger_error($this->language->lang($message) . ' ' . $this->language->lang('RETURN_PAGE', '', ' '), E_USER_WARNING);
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..67ba99f
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+ ./tests
+
+
+
+
+
+ ./
+
+ ./language/
+ ./migrations/
+ ./tests/
+
+
+
+
diff --git a/styles/all/template/event/mcp_post_text_before.html b/styles/all/template/event/mcp_post_text_before.html
new file mode 100644
index 0000000..5e0015a
--- /dev/null
+++ b/styles/all/template/event/mcp_post_text_before.html
@@ -0,0 +1,6 @@
+{% if S_SEENOTES %}
+
+
+ {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': _context} only %}
+
+{% endif %}
diff --git a/styles/all/template/event/mcp_topic_post_author_full_append.html b/styles/all/template/event/mcp_topic_post_author_full_append.html
new file mode 100644
index 0000000..b3165b3
--- /dev/null
+++ b/styles/all/template/event/mcp_topic_post_author_full_append.html
@@ -0,0 +1 @@
+{% if postrow.S_SEENOTES %} • {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': postrow} only %}{% endif %}
diff --git a/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
new file mode 100644
index 0000000..6526d27
--- /dev/null
+++ b/styles/all/template/event/memberlist_view_non_contact_custom_fields_after.html
@@ -0,0 +1,4 @@
+{% if S_SEENOTES %}
+ {{ SEENOTES_LABEL }}{{ lang('COLON') }}
+ {% include '@phpbbmodders_seenotes/seenotes_reason_link.html' with {'sn': _context} only %}
+{% endif %}
diff --git a/styles/all/template/event/overall_header_head_append.html b/styles/all/template/event/overall_header_head_append.html
new file mode 100644
index 0000000..30c11ec
--- /dev/null
+++ b/styles/all/template/event/overall_header_head_append.html
@@ -0,0 +1,3 @@
+{% if S_SEENOTES_CSS %}
+ {% INCLUDECSS '@phpbbmodders_seenotes/seenotes.css' %}
+{% endif %}
diff --git a/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html b/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html
new file mode 100644
index 0000000..007d12d
--- /dev/null
+++ b/styles/all/template/event/viewtopic_body_postrow_custom_fields_after.html
@@ -0,0 +1,3 @@
+{% if postrow.S_SEENOTES %}
+ {% include '@phpbbmodders_seenotes/seenotes_reason.html' with {'sn': postrow} only %}
+{% endif %}
diff --git a/styles/all/template/mcp_seenotes.html b/styles/all/template/mcp_seenotes.html
new file mode 100644
index 0000000..bae06ec
--- /dev/null
+++ b/styles/all/template/mcp_seenotes.html
@@ -0,0 +1,214 @@
+{% include 'mcp_header.html' %}
+
+{{ lang('MCP_SEENOTES_NOTES') }}
+
+{% if S_SEENOTES_FRONT %}
+
+
+
+
+
+
+
+
+
+{% else %}
+
+
+
+
{{ SEENOTES_MEMBER }}
+
+ {% if SEENOTES_ERROR %}
{{ SEENOTES_ERROR }}
{% endif %}
+
+
{{ lang('SEENOTES_ENTRIES') }}
+
+ {% for entry in loops.seenotes_entries %}
+
+
+
+ {{ entry.REASON }}
+ {% if entry.S_CURRENT %}{{ lang('SEENOTES_CURRENT') }} {% endif %}
+ {% if not entry.S_ACTIVE %}{{ lang('SEENOTES_INACTIVE') }} {% endif %}
+ {{ entry.NOTE_COUNT }}
+
+ {% if entry.DESCRIPTION %}
{{ entry.DESCRIPTION }}
{% endif %}
+
{{ lang('SEENOTES_ADDED_BY', entry.AUTHOR, entry.TIME) }}
+
+ {% if S_SEENOTES_CAN_MANAGE or S_SEENOTES_CAN_DELETE_ENTRY %}
+
+ {% if S_SEENOTES_CAN_MANAGE %}
+
{{ lang('SEENOTES_ADD_NOTE') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% if not entry.S_CURRENT %}
+
+ {{ lang('SEENOTES_MAKE_CURRENT') }}{{ lang('COLON') }} {{ entry.REASON }}
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+ {% endif %}
+ {% endif %}
+ {% if S_SEENOTES_CAN_DELETE_ENTRY %}
+
{{ lang('SEENOTES_DELETE_ENTRY') }}{{ lang('COLON') }} {{ entry.REASON }}
+ {% endif %}
+
+ {% endif %}
+
+ {% for note in entry.notes %}
+
+
+ {{ note.TEXT }}
+ {% if note.EDITED %}{{ note.EDITED }}
{% endif %}
+
+ {% else %}
+
{{ lang('SEENOTES_NO_NOTES') }}
+ {% endfor %}
+
+
+ {% else %}
+
{{ lang('SEENOTES_NO_ENTRIES') }}
+ {% endfor %}
+
+ {% if S_SEENOTES_CAN_MANAGE and S_SEENOTES_HAS_CURRENT %}
+
+
+ {{ lang('SEENOTES_CLEAR_CURRENT') }}
+ {{ lang('SEENOTES_CLEAR_CURRENT_EXPLAIN') }}
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% endif %}
+
+
+
+{% if S_SEENOTES_EDITOR %}
+
+
+
+
{% if S_SEENOTES_EDITING %}{{ lang('SEENOTES_EDIT_NOTE') }}{% else %}{{ lang('SEENOTES_ADD_NOTE') }}{% endif %}
+
+ {% if not S_SEENOTES_EDITING %}
+
+ {{ lang('SEENOTES_NOTE_FOR') }}{{ lang('COLON') }}
+
+
+ {% for option in loops.seenotes_editor_entries %}
+ {{ option.TITLE }}
+ {% endfor %}
+
+
+
+ {% endif %}
+ {{ lang('SEENOTES_NOTE_TEXT') }}
+ {% include 'posting_buttons.html' %}
+
+ {{ SEENOTES_EDITOR_TEXT }}
+
+ {{ SEENOTES_FORMAT_STATUS }}
+
+
+
+
+
+ {% if S_SEENOTES_EDITING %} {% endif %}
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+ {% if S_SEENOTES_EDITING %}{{ lang('CANCEL') }} {% endif %}
+
+
+
+{% endif %}
+
+{% if S_SEENOTES_CAN_MANAGE %}
+
+
+
+
{{ lang('SEENOTES_ADD_ENTRY') }}
+ {% if loops.seenotes_reasons|length %}
+
{{ lang('SEENOTES_ADD_ENTRY_EXPLAIN') }}
+
+
+ {{ lang('SEENOTES_REASON') }}{{ lang('COLON') }}
+
+
+ {{ lang('SEENOTES_SELECT_REASON') }}
+ {% for reason in loops.seenotes_reasons %}
+ {{ reason.TITLE }}
+ {% endfor %}
+
+
+
+
+
+ {{ lang('SEENOTES_MAKE_IT_CURRENT') }}
+
+
+ {% else %}
+
{{ lang('SEENOTES_NO_REASONS') }}
+ {% endif %}
+
+
+ {% if loops.seenotes_reasons|length %}
+
+
+ {{ S_SEENOTES_HIDDEN_USER }}
+ {{ S_FORM_TOKEN }}
+
+
+ {% endif %}
+
+{% endif %}
+
+
+
+
{{ lang('SEENOTES_USERNOTES') }}
+
{{ lang('SEENOTES_USERNOTES_EXPLAIN') }}
+ {% if loops.seenotes_usernotes|length %}
+
+
+
+ {{ lang('REPORT_BY') }}
+ {{ lang('TIME') }}
+ {{ lang('ACTION_NOTE') }}
+
+
+
+ {% for usernote in loops.seenotes_usernotes %}
+
+ {{ usernote.REPORT_BY }}
+ {{ usernote.REPORT_AT }}
+ {{ usernote.ACTION }}
+
+ {% endfor %}
+
+
+
{{ SEENOTES_USERNOTES_TOTAL }} • {{ lang('SEENOTES_USERNOTES_ALL') }}
+ {% else %}
+
{{ lang('SEENOTES_NO_USERNOTES') }}
+ {% endif %}
+
+
+
+{% endif %}
+
+{% include 'mcp_footer.html' %}
diff --git a/styles/all/template/seenotes_reason.html b/styles/all/template/seenotes_reason.html
new file mode 100644
index 0000000..8278e93
--- /dev/null
+++ b/styles/all/template/seenotes_reason.html
@@ -0,0 +1,5 @@
+{# The reason line. Pass the row with the SEENOTES_* values as "sn". #}
+
+ {{ sn.SEENOTES_LABEL }}{{ lang('COLON') }}
+ {% include '@phpbbmodders_seenotes/seenotes_reason_link.html' with {'sn': sn} only %}
+
diff --git a/styles/all/template/seenotes_reason_link.html b/styles/all/template/seenotes_reason_link.html
new file mode 100644
index 0000000..73edf0c
--- /dev/null
+++ b/styles/all/template/seenotes_reason_link.html
@@ -0,0 +1,2 @@
+{# The reason, or NONE, as a link to the notes. Pass the row with the SEENOTES_* values as "sn". #}
+{% if sn.U_SEENOTES %}{{ sn.SEENOTES_REASON }}{% if not sn.S_SEENOTES_NONE %} ({{ sn.SEENOTES_COUNT }}) {% endif %}{% if not sn.S_SEENOTES_NONE %}, {{ sn.SEENOTES_COUNT_TEXT }}{% if sn.SEENOTES_DESCRIPTION %}: {{ sn.SEENOTES_DESCRIPTION }}{% endif %}{% endif %}{% if sn.U_SEENOTES %}. {{ sn.SEENOTES_LINK_TEXT }}{% endif %} {% if sn.U_SEENOTES %} {% else %}{% endif %}
diff --git a/styles/all/theme/seenotes.css b/styles/all/theme/seenotes.css
new file mode 100644
index 0000000..1a2c357
--- /dev/null
+++ b/styles/all/theme/seenotes.css
@@ -0,0 +1,82 @@
+/* See Notes: the reason line and the Moderator notes page */
+
+.seenotes-line,
+.seenotes-line a.seenotes-reason {
+ font-weight: bold;
+}
+
+/* Note actions large enough to tap (WCAG 2.2 target size). The reason
+ link stays inline, like the profile's other links, so it lines up with
+ its label. */
+.seenotes-note-actions a {
+ display: inline-block;
+ min-height: 24px;
+ line-height: 24px;
+}
+
+.seenotes-notice {
+ border-left: 4px solid currentColor;
+ padding: 4px 8px;
+}
+
+.seenotes-entry {
+ margin-bottom: 10px;
+}
+
+.seenotes-entry-head {
+ font-size: 1.2em;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 12px;
+ margin-bottom: 6px;
+}
+
+.seenotes-entry-title {
+ font-size: 1.15em;
+ font-weight: bold;
+}
+
+.seenotes-badge {
+ border: 1px solid currentColor;
+ border-radius: 3px;
+ font-size: 0.9em;
+ padding: 0 4px;
+}
+
+.seenotes-entry-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 6px 0;
+}
+
+.seenotes-entry-actions form {
+ display: inline;
+}
+
+.seenotes-note {
+ border-top: 1px solid rgba(128, 128, 128, 0.3);
+ padding: 6px 0;
+}
+
+.seenotes-note-meta {
+ font-size: 1.1em;
+ margin-bottom: 4px;
+}
+
+.seenotes-note-actions a {
+ margin-left: 8px;
+}
+
+/* Note text the size of post text (phpBB sizes that through .postbody) */
+.seenotes-note .content {
+ font-size: 1.3em;
+ min-height: 0;
+ overflow-x: auto;
+}
+
+/* NONE: no current reason, so a quieter line in the normal text color */
+.seenotes-notice-none {
+ border-left-color: rgba(128, 128, 128, 0.5);
+}
diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php
new file mode 100644
index 0000000..73d0edf
--- /dev/null
+++ b/tests/event/listener_test.php
@@ -0,0 +1,168 @@
+auth = $this->getMockBuilder('\phpbb\auth\auth')->disableOriginalConstructor()->getMock();
+ $this->template = $this->getMockBuilder('\phpbb\template\template')->getMock();
+ $this->manager = $this->getMockBuilder(manager::class)->disableOriginalConstructor()->getMock();
+ $this->manager->method('get_label')->willReturn('SEE NOTES');
+ $this->manager->method('clean_color')->willReturnArgument(0);
+ $this->notes_page = $this->getMockBuilder(notes_page::class)->disableOriginalConstructor()->getMock();
+ }
+
+ /**
+ * @return listener
+ */
+ protected function get_listener()
+ {
+ global $phpbb_root_path, $phpEx;
+
+ $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock();
+ $language->method('lang')->willReturnArgument(0);
+ $user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock();
+ $user->session_id = '';
+
+ return new listener($this->auth, $language, $this->template, $user, $this->manager, $this->notes_page, $phpbb_root_path, $phpEx);
+ }
+
+ public function test_construct()
+ {
+ $this->assertInstanceOf('\Symfony\Component\EventDispatcher\EventSubscriberInterface', $this->get_listener());
+ }
+
+ public function test_subscribed_events()
+ {
+ $this->assertEquals([
+ 'core.user_setup',
+ 'core.permissions',
+ 'core.page_header',
+ 'core.memberlist_view_profile',
+ 'core.viewtopic_modify_post_data',
+ 'core.viewtopic_modify_post_row',
+ 'core.mcp_post_template_data',
+ 'core.mcp_report_template_data',
+ 'core.mcp_queue_approve_details_template',
+ 'core.mcp_topic_modify_post_data',
+ 'core.mcp_topic_review_modify_row',
+ 'core.acp_users_mode_add',
+ 'core.delete_user_after',
+ 'core.update_username',
+ ], array_keys(listener::getSubscribedEvents()));
+ }
+
+ public function test_permissions_are_added_in_their_own_category()
+ {
+ $event = new data(['categories' => [], 'permissions' => []]);
+ $this->get_listener()->add_permissions($event);
+
+ $this->assertArrayHasKey('seenotes', $event['categories']);
+ $this->assertCount(11, $event['permissions']);
+ $this->assertSame('seenotes', $event['permissions']['m_seenotes_note_edit']['cat']);
+ $this->assertArrayHasKey('a_seenotes_settings', $event['permissions']);
+ }
+
+ public function test_post_row_unchanged_for_viewers_without_permission()
+ {
+ $this->manager->method('can')->willReturn(false);
+ $this->manager->expects($this->never())->method('get_current_for_users');
+
+ $event = new data(['poster_id' => 3, 'post_row' => ['POST_ID' => 1]]);
+ $this->get_listener()->show_on_post($event);
+
+ $this->assertSame(['POST_ID' => 1], $event['post_row']);
+ }
+
+ public function test_post_row_gets_the_current_reason()
+ {
+ $this->manager->method('can')->willReturn(true);
+ $this->manager->method('get_current_for_users')->willReturn([
+ 3 => ['reason_title' => 'Moderation queue', 'reason_description' => 'Approve first', 'reason_color' => 'AA0000', 'note_count' => 2],
+ ]);
+ $this->auth->method('acl_getf_global')->willReturn(true);
+
+ // append_sid() runs its event
+ global $phpbb_dispatcher;
+ $phpbb_dispatcher = new \phpbb_mock_event_dispatcher();
+
+ $event = new data(['poster_id' => 3, 'post_row' => []]);
+ $this->get_listener()->show_on_post($event);
+
+ $row = $event['post_row'];
+ $this->assertTrue($row['S_SEENOTES']);
+ $this->assertSame('SEE NOTES', $row['SEENOTES_LABEL']);
+ $this->assertSame('Moderation queue', $row['SEENOTES_REASON']);
+ $this->assertSame(2, $row['SEENOTES_COUNT']);
+ $this->assertStringContainsString('mode=notes&u=3', $row['U_SEENOTES']);
+ }
+
+ public function test_post_row_shows_none_without_a_current_reason()
+ {
+ $this->manager->method('can')->willReturn(true);
+ $this->manager->method('get_current_for_users')->willReturn([]);
+ $this->auth->method('acl_getf_global')->willReturn(true);
+
+ // append_sid() runs its event
+ global $phpbb_dispatcher;
+ $phpbb_dispatcher = new \phpbb_mock_event_dispatcher();
+
+ $event = new data(['poster_id' => 3, 'post_row' => []]);
+ $this->get_listener()->show_on_post($event);
+
+ $row = $event['post_row'];
+ $this->assertTrue($row['S_SEENOTES_NONE']);
+ $this->assertSame('SEENOTES_NONE', $row['SEENOTES_REASON']);
+ $this->assertStringEndsWith('u=3#seenotes-add-entry', $row['U_SEENOTES']);
+ }
+
+ public function test_other_acp_users_modes_are_ignored()
+ {
+ $this->notes_page->expects($this->never())->method('handle');
+
+ $event = new data(['mode' => 'feedback', 'user_id' => 3, 'user_row' => [], 'u_action' => 'x']);
+ $this->get_listener()->acp_users_page($event);
+ }
+
+ public function test_acp_users_mode_shows_the_page()
+ {
+ $this->notes_page->expects($this->once())
+ ->method('handle')
+ ->with(['user_id' => 3], 'index.php?i=users&mode=seenotes&u=3', true);
+
+ $event = new data(['mode' => 'seenotes', 'user_id' => 3, 'user_row' => ['user_id' => 3], 'u_action' => 'index.php?i=users&mode=seenotes']);
+ $this->get_listener()->acp_users_page($event);
+ }
+}
diff --git a/tests/fixtures/seenotes.xml b/tests/fixtures/seenotes.xml
new file mode 100644
index 0000000..c079e1d
--- /dev/null
+++ b/tests/fixtures/seenotes.xml
@@ -0,0 +1,159 @@
+
+
+
+ user_id
+ username
+ username_clean
+ user_colour
+ user_permissions
+ user_sig
+
+ 2
+ admin
+ admin
+ AA0000
+
+
+
+
+ 3
+ member
+ member
+
+
+
+
+
+ 4
+ other
+ other
+
+
+
+
+
+
+ reason_id
+ reason_title
+ reason_description
+ reason_color
+ reason_order
+ reason_active
+
+ 1
+ Moderation queue
+ Posts must be approved
+ AA0000
+ 1
+ 1
+
+
+ 2
+ Spam watch
+
+ 0000AA
+ 2
+ 1
+
+
+ 3
+ Retired
+
+ 00AA00
+ 3
+ 0
+
+
+
+ entry_id
+ user_id
+ reason_id
+ entry_time
+ entry_author_id
+ entry_author_name
+ entry_current
+
+ 1
+ 3
+ 1
+ 1000
+ 2
+ admin
+ 1
+
+
+ 2
+ 3
+ 3
+ 900
+ 2
+ admin
+ 0
+
+
+ 3
+ 4
+ 1
+ 1100
+ 2
+ admin
+ 0
+
+
+
+ note_id
+ entry_id
+ note_author_id
+ note_author_name
+ note_time
+ note_text
+ note_bbcode_uid
+ note_bbcode_bitfield
+ note_bbcode_options
+ note_edit_time
+ note_edit_user_id
+ note_edit_user_name
+
+ 1
+ 1
+ 2
+ admin
+ 1000
+ <t>First note</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+ 2
+ 1
+ 99
+ gone
+ 1001
+ <t>Note by a deleted member</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+ 3
+ 3
+ 2
+ admin
+ 1100
+ <t>Other member</t>
+
+
+ 7
+ 0
+ 0
+
+
+
+
diff --git a/tests/functional/.gitkeep b/tests/functional/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/manager_test.php b/tests/manager_test.php
new file mode 100644
index 0000000..f335913
--- /dev/null
+++ b/tests/manager_test.php
@@ -0,0 +1,230 @@
+createXMLDataSet(__DIR__ . '/fixtures/seenotes.xml');
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->db = $this->new_dbal();
+ $this->granted = [];
+
+ $this->auth = $this->getMockBuilder('\phpbb\auth\auth')->disableOriginalConstructor()->getMock();
+ $this->auth->method('acl_get')->willReturnCallback(function ($option) {
+ return in_array($option, $this->granted, true);
+ });
+ $this->auth->method('acl_getf_global')->willReturnCallback(function ($option) {
+ return in_array($option, $this->granted, true);
+ });
+
+ $this->log = $this->getMockBuilder('\phpbb\log\log_interface')->getMock();
+
+ $this->user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock();
+ $this->user->data = ['user_id' => 2, 'username' => 'admin', 'is_registered' => true];
+ $this->user->ip = '127.0.0.1';
+ }
+
+ /**
+ * @return manager
+ */
+ protected function get_manager()
+ {
+ $config = new \phpbb\config\config(['seenotes_label' => 'SEE NOTES']);
+
+ return new manager($this->auth, $config, $this->db, $this->log, $this->user, 'phpbb_seenotes_reasons', 'phpbb_seenotes_entries', 'phpbb_seenotes_notes');
+ }
+
+ public function test_moderator_or_admin_permission_allows_an_action()
+ {
+ $manager = $this->get_manager();
+ $this->assertFalse($manager->can('view'));
+
+ $this->granted = ['m_seenotes_view'];
+ $this->assertTrue($manager->can('view'));
+ $this->assertFalse($manager->can('manage'));
+
+ $this->granted = ['a_seenotes_manage'];
+ $this->assertTrue($manager->can('manage'));
+ }
+
+ public function test_guests_and_unknown_actions_are_refused()
+ {
+ $this->granted = ['m_seenotes_view', 'm_seenotes_settings'];
+ $manager = $this->get_manager();
+ $this->assertFalse($manager->can('settings'));
+
+ $this->user->data['is_registered'] = false;
+ $this->assertFalse($manager->can('view'));
+ }
+
+ public function test_clean_color()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame('00AAFF', $manager->clean_color('#00aaff'));
+ $this->assertSame(manager::DEFAULT_COLOR, $manager->clean_color('red'));
+ $this->assertSame(manager::DEFAULT_COLOR, $manager->clean_color('#00aaff;background:url(x)'));
+ }
+
+ public function test_active_reasons_leave_out_switched_off_ones()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame([1, 2, 3], array_keys($manager->get_reasons()));
+ $this->assertSame([1, 2], array_keys($manager->get_reasons(true)));
+ }
+
+ public function test_new_reason_goes_last_and_moves()
+ {
+ $manager = $this->get_manager();
+ $id = $manager->save_reason(['reason_title' => 'New', 'reason_description' => '', 'reason_color' => '#123abc']);
+
+ $this->assertSame([1, 2, 3, $id], array_keys($manager->get_reasons()));
+ $this->assertSame('123ABC', $manager->get_reason($id)['reason_color']);
+
+ $this->assertTrue($manager->move_reason($id, true));
+ $this->assertSame([1, 2, $id, 3], array_keys($manager->get_reasons()));
+ $this->assertFalse($manager->move_reason(1, true));
+ }
+
+ public function test_current_entry_with_note_count()
+ {
+ $current = $this->get_manager()->get_current_for_users([3, 4]);
+
+ $this->assertSame([3], array_keys($current));
+ $this->assertSame('Moderation queue', $current[3]['reason_title']);
+ $this->assertSame(2, $current[3]['note_count']);
+ }
+
+ public function test_set_current_keeps_one_current_entry()
+ {
+ $manager = $this->get_manager();
+
+ $manager->set_current(3, 2);
+ $entries = $manager->get_entries(3);
+ $this->assertSame(0, (int) $entries[1]['entry_current']);
+ $this->assertSame(1, (int) $entries[2]['entry_current']);
+
+ $manager->set_current(3, 0);
+ $this->assertSame([], $manager->get_current_for_users([3]));
+ }
+
+ public function test_add_entry_as_current()
+ {
+ $manager = $this->get_manager();
+ $entry_id = $manager->add_entry(4, 2, true);
+
+ $current = $manager->get_current_for_users([4]);
+ $this->assertSame($entry_id, (int) $current[4]['entry_id']);
+ $this->assertSame('admin', $manager->get_entry($entry_id)['entry_author_name']);
+ }
+
+ public function test_notes_newest_first_with_saved_name_for_deleted_author()
+ {
+ $notes = $this->get_manager()->get_notes([1]);
+
+ $this->assertSame([2, 1], array_map('intval', array_column($notes[1], 'note_id')));
+ // No users row: NULL, or '' on MSSQL, whose phpBB driver returns NULL as an empty string
+ $this->assertEmpty($notes[1][0]['author_username']);
+ $this->assertSame('gone', $notes[1][0]['note_author_name']);
+ $this->assertSame('admin', $notes[1][1]['author_username']);
+ }
+
+ public function test_delete_entry_removes_its_notes()
+ {
+ $manager = $this->get_manager();
+ $manager->delete_entry(1);
+
+ $this->assertNull($manager->get_entry(1));
+ $this->assertNull($manager->get_note(1));
+ $this->assertNotNull($manager->get_note(3));
+ }
+
+ public function test_reason_usage_and_hard_delete()
+ {
+ $manager = $this->get_manager();
+
+ $this->assertSame(['users' => 2, 'entries' => 2, 'notes' => 3], $manager->count_reason_usage(1));
+
+ $manager->delete_reason(1);
+ $this->assertNull($manager->get_reason(1));
+ $this->assertSame([2], array_keys($manager->get_entries(3)));
+ $this->assertSame([], $manager->get_entries(4));
+ $this->assertNull($manager->get_note(3));
+ }
+
+ public function test_deleting_a_member_deletes_their_records()
+ {
+ $manager = $this->get_manager();
+ $manager->delete_users([3]);
+
+ $this->assertSame([], $manager->get_entries(3));
+ $this->assertNull($manager->get_note(1));
+ $this->assertSame([3], array_keys($manager->get_entries(4)));
+ }
+
+ public function test_renaming_updates_only_the_renamed_members_names()
+ {
+ $manager = $this->get_manager();
+
+ // A deleted member (99) who had the same name as the renamed one
+ $this->db->sql_query("UPDATE phpbb_seenotes_notes SET note_author_name = 'admin' WHERE note_id = 2");
+ // phpBB renames the users row before core.update_username runs
+ $this->db->sql_query("UPDATE phpbb_users SET username = 'boss', username_clean = 'boss' WHERE user_id = 2");
+
+ $manager->update_username('admin', 'boss');
+
+ $this->assertSame('boss', $manager->get_entry(1)['entry_author_name']);
+ $this->assertSame('boss', $manager->get_note(1)['note_author_name']);
+ $this->assertSame('admin', $manager->get_note(2)['note_author_name']);
+ }
+
+ public function test_moderator_log_has_no_forum()
+ {
+ $this->log->expects($this->once())
+ ->method('add')
+ ->with('mod', 2, '127.0.0.1', 'LOG_SEENOTES_NOTE_ADDED', false, ['forum_id' => 0, 'topic_id' => 0, 0 => 'Moderation queue', 1 => 'member']);
+
+ $this->get_manager()->log_moderator('LOG_SEENOTES_NOTE_ADDED', ['Moderation queue', 'member']);
+ }
+}