From 0520fd5b769be8410ce8f64fb4b2aef0bf4fcdf9 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sat, 19 Sep 2026 13:48:05 -0700 Subject: [PATCH 01/32] Initial total redesign from title prefixes to topic tags --- CHANGELOG.md | 10 + README.md | 15 +- acp/topic_prefixes_module.php | 2 +- adm/style/acp_topic_prefixes.css | 26 +- adm/style/acp_topic_prefixes.html | 133 ++--- adm/style/acp_topic_prefixes.js | 4 - composer.json | 8 +- config/services.yml | 66 ++- config/tables.yml | 2 + controller/admin_controller.php | 345 ++++++------- event/display_listener.php | 105 ++++ event/listener.php | 203 -------- event/posting_listener.php | 209 ++++++++ event/viewforum_listener.php | 255 +++++++++ ext.php | 4 +- language/en/acp_topic_prefixes.php | 49 +- language/en/info_acp_topic_prefixes.php | 20 +- language/en/topic_prefixes.php | 14 +- migrations/v200_cleanup.php | 75 +++ migrations/v200_data.php | 277 ++++++++++ migrations/v200_schema.php | 97 ++++ prefixes/manager.php | 121 ----- prefixes/manager_interface.php | 94 ---- prefixes/nestedset_prefixes.php | 79 --- .../event/overall_header_head_append.html | 1 + .../event/posting_editor_subject_before.html | 19 +- .../search_results_topic_title_after.html | 7 + .../template/event/topiclist_row_prepend.html | 13 + .../viewforum_body_topic_row_before.html | 13 + .../event/viewtopic_topic_title_append.html | 7 + styles/all/template/js/topic_prefixes.js | 25 - styles/all/theme/topic_tags.css | 79 +++ tags/assignment_manager.php | 121 +++++ tags/filter.php | 102 ++++ tags/manager.php | 436 ++++++++++++++++ tags/renderer.php | 129 +++++ tests/acp/acp_module_test.php | 22 +- tests/controller/add_prefix_test.php | 91 ---- tests/controller/admin_controller_base.php | 144 ------ tests/controller/admin_controller_test.php | 104 ++++ tests/controller/delete_prefix_test.php | 95 ---- tests/controller/display_settings_test.php | 76 --- tests/controller/edit_prefix_test.php | 101 ---- tests/controller/main_test.php | 78 --- tests/controller/move_prefix_test.php | 100 ---- tests/dbal/assignment_manager_test.php | 37 ++ tests/dbal/filter_test.php | 48 ++ tests/dbal/fixtures/topic_prefixes.xml | 73 --- tests/dbal/fixtures/topic_tags.xml | 32 ++ tests/dbal/legacy_migration_test.php | 110 ++++ tests/dbal/manager_add_prefix_test.php | 51 -- tests/dbal/manager_base.php | 61 --- tests/dbal/manager_delete_prefix_test.php | 48 -- .../dbal/manager_get_active_prefixes_test.php | 45 -- tests/dbal/manager_get_prefix_test.php | 55 -- tests/dbal/manager_get_prefixes_test.php | 45 -- tests/dbal/manager_move_prefix_test.php | 121 ----- tests/dbal/manager_prepend_prefix_test.php | 45 -- tests/dbal/manager_update_prefix_test.php | 51 -- tests/dbal/simple_test.php | 49 -- tests/dbal/tag_manager_test.php | 81 +++ tests/dbal/tags_base.php | 53 ++ tests/event/listener_test.php | 485 ------------------ tests/event/posting_listener_test.php | 124 +++++ tests/event/viewforum_listener_test.php | 51 ++ tests/functional/functional_test.php | 204 +++----- tests/tags/renderer_test.php | 22 + 67 files changed, 3071 insertions(+), 2796 deletions(-) create mode 100644 event/display_listener.php delete mode 100644 event/listener.php create mode 100644 event/posting_listener.php create mode 100644 event/viewforum_listener.php create mode 100644 migrations/v200_cleanup.php create mode 100644 migrations/v200_data.php create mode 100644 migrations/v200_schema.php delete mode 100644 prefixes/manager.php delete mode 100644 prefixes/manager_interface.php delete mode 100644 prefixes/nestedset_prefixes.php create mode 100644 styles/all/template/event/overall_header_head_append.html create mode 100644 styles/all/template/event/search_results_topic_title_after.html create mode 100644 styles/all/template/event/topiclist_row_prepend.html create mode 100644 styles/all/template/event/viewforum_body_topic_row_before.html create mode 100644 styles/all/template/event/viewtopic_topic_title_append.html delete mode 100644 styles/all/template/js/topic_prefixes.js create mode 100644 styles/all/theme/topic_tags.css create mode 100644 tags/assignment_manager.php create mode 100644 tags/filter.php create mode 100644 tags/manager.php create mode 100644 tags/renderer.php delete mode 100644 tests/controller/add_prefix_test.php delete mode 100644 tests/controller/admin_controller_base.php create mode 100644 tests/controller/admin_controller_test.php delete mode 100644 tests/controller/delete_prefix_test.php delete mode 100644 tests/controller/display_settings_test.php delete mode 100644 tests/controller/edit_prefix_test.php delete mode 100644 tests/controller/main_test.php delete mode 100644 tests/controller/move_prefix_test.php create mode 100644 tests/dbal/assignment_manager_test.php create mode 100644 tests/dbal/filter_test.php delete mode 100644 tests/dbal/fixtures/topic_prefixes.xml create mode 100644 tests/dbal/fixtures/topic_tags.xml create mode 100644 tests/dbal/legacy_migration_test.php delete mode 100644 tests/dbal/manager_add_prefix_test.php delete mode 100644 tests/dbal/manager_base.php delete mode 100644 tests/dbal/manager_delete_prefix_test.php delete mode 100644 tests/dbal/manager_get_active_prefixes_test.php delete mode 100644 tests/dbal/manager_get_prefix_test.php delete mode 100644 tests/dbal/manager_get_prefixes_test.php delete mode 100644 tests/dbal/manager_move_prefix_test.php delete mode 100644 tests/dbal/manager_prepend_prefix_test.php delete mode 100644 tests/dbal/manager_update_prefix_test.php delete mode 100644 tests/dbal/simple_test.php create mode 100644 tests/dbal/tag_manager_test.php create mode 100644 tests/dbal/tags_base.php delete mode 100644 tests/event/listener_test.php create mode 100644 tests/event/posting_listener_test.php create mode 100644 tests/event/viewforum_listener_test.php create mode 100644 tests/tags/renderer_test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b83dea8..610a4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +### 2.0.0 - 2026-09-19 + +- Replaced title text prefixes with relational topic tag metadata. +- Added multiple colored tags per topic and automatic badge text contrast. +- Added global ACP tag management with forum availability controls. +- Added accessible tag selection when creating topics and editing first posts. +- Added tag badges to forum, topic, and search displays. +- Added multi-tag AND filtering to forum topic lists. +- Added conservative migration of legacy definitions, assignments, titles, and first-post subjects. + ### 1.0.2 - 2025-12-17 - Strengthened compatibility with both phpBB3 and phpBB4-alpha-1. diff --git a/README.md b/README.md index 1d07044..4d03f56 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ This is the repository for the development of the Topic Prefixes phpBB extension [![Build Status](https://github.com/phpbb-extensions/topicprefixes/actions/workflows/tests.yml/badge.svg)](https://github.com/phpbb-extensions/topicprefixes/actions) [![codecov](https://codecov.io/gh/phpbb-extensions/topicprefixes/branch/master/graph/badge.svg?token=Dc0GWOeQWj)](https://codecov.io/gh/phpbb-extensions/topicprefixes) -The phpBB Topic Prefixes extension allows phpBB board administrators to create topic prefix tags that users can easily add to their topic titles. Features include: - -- Create unique and unlimited prefixes for each forum in your phpBB board. -- Admins can sort, enable and disable prefix tags. -- Users will see a topic prefix menu when creating a new topic in a forum that uses topic prefixes. -- Topic prefixes can easily be changed when a user is editing their original first post. -- Because the topic prefix is added directly to the topic title, if an admin disables or deletes a topic prefix, existing topic titles using the deleted prefix will not be changed. +The phpBB Topic Prefixes extension provides administrator-curated topic tags. Tags are structured topic metadata and are displayed separately from topic titles as colored badges. Features include: + +- Create, edit, order, enable, disable, and color topic tags. +- Make each tag available in one or more forums. +- Assign multiple tags while creating a topic or editing its first post. +- Display readable colored badges in topic lists, topic pages, and search results. +- Filter forum topic lists by one or more tags using AND semantics. +- Upgrade existing title prefixes safely into relational tag assignments. - This is the same extension currently in use at phpbb.com in the Extensions and Styles in development forums. πŸ“¦ [Download](https://www.phpbb.com/customise/db/extension/topicprefixes/) the latest release of this extension. diff --git a/acp/topic_prefixes_module.php b/acp/topic_prefixes_module.php index e13021b..d3b45f7 100644 --- a/acp/topic_prefixes_module.php +++ b/acp/topic_prefixes_module.php @@ -33,7 +33,7 @@ public function main() $lang->add_lang('acp/forums'); $lang->add_lang('acp_topic_prefixes', 'phpbb/topicprefixes'); $this->tpl_name = 'acp_topic_prefixes'; - $this->page_title = 'TOPIC_PREFIXES'; + $this->page_title = 'TOPIC_TAGS'; $admin_controller = $phpbb_container->get('phpbb.topicprefixes.admin_controller'); $admin_controller->set_u_action($this->u_action)->main(); diff --git a/adm/style/acp_topic_prefixes.css b/adm/style/acp_topic_prefixes.css index 3c73b32..35e1aa4 100644 --- a/adm/style/acp_topic_prefixes.css +++ b/adm/style/acp_topic_prefixes.css @@ -3,10 +3,32 @@ font-weight: 900; } -.tp-toggle-on:before { +.tp-toggle-on::before { content: "\f205"; } -.tp-toggle-off:before { +.tp-toggle-off::before { content: "\f204"; } + +.topic-tag { + border-radius: 3px; + display: inline-block; + font-weight: 600; + line-height: 1.5; + padding: 1px 7px; +} + +.topic-tags-empty { + text-align: center; +} + +.topic-tag-forums { + list-style: disc; + margin: 0 0 0 1.25em; + padding: 0; +} + +#forum_ids { + min-width: 22em; +} diff --git a/adm/style/acp_topic_prefixes.html b/adm/style/acp_topic_prefixes.html index 9677ca3..cd01a1d 100644 --- a/adm/style/acp_topic_prefixes.html +++ b/adm/style/acp_topic_prefixes.html @@ -2,70 +2,79 @@ -

{{ lang('TOPIC_PREFIXES') }}

+

{{ lang('TOPIC_TAGS') }}

+

{{ lang('TOPIC_TAGS_EXPLAIN') }}

-

{{ lang('TOPIC_PREFIXES_EXPLAIN') }}

+ + + + + + + + + + + {% for tag in loops.tags %} + + + + + + + {% else %} + + {% endfor %} + +
{{ lang('TOPIC_TAG') }}{{ lang('TOPIC_TAG_FORUMS') }}{{ lang('TOPIC_TAG_ENABLED') }}{{ lang('ACTION') }}
{{ tag.TAG_NAME }} + {% if tag.FORUM_NAMES %} +
    + {% for forum_name in tag.FORUM_NAMES %}
  • {{ forum_name }}
  • {% endfor %} +
+ {% else %} + {{ lang('TOPIC_TAG_NO_FORUMS') }} + {% endif %} +
+ + + + + + {{ ICON_MOVE_UP }} + + {{ ICON_MOVE_DOWN }} + {{ ICON_EDIT }} + {{ ICON_DELETE }} +
{{ lang('TOPIC_TAGS_EMPTY') }}
-
-
-
-
-
- - -
-
-
-
- -{% if FORUM_ID %} - - - - - - - - - - - {% for prefix in loops.prefixes %} - - - - - - {% else %} - - - - {% endfor %} - -
{{ lang('TOPIC_PREFIX_TAG') }}{{ lang('TOPIC_PREFIX_ENABLED') }}{{ lang('ACTION') }}
{{ prefix.PREFIX_TAG }} - - - - - - {{ ICON_MOVE_UP }} - - {{ ICON_MOVE_DOWN }} - {{ ICON_DELETE }} -
{{ lang('TOPIC_PREFIXES_EMPTY') }}
- -
-
- {{ lang('CREATE_TOPIC_PREFIX') ~ lang('COLON') }} - - - - - {{ S_FORM_TOKEN }} - {{ S_HIDDEN_FIELDS }} -
-
- -{% endif %} +
+
+ {{ lang(S_EDIT_TAG ? 'EDIT_TOPIC_TAG' : 'CREATE_TOPIC_TAG') }} +
+
+
+
+
+

{{ lang('TOPIC_TAG_COLOR_EXPLAIN') }}
+
+
+
+
+
+
+
+

{{ lang('TOPIC_TAG_FORUMS_EXPLAIN') }}
+
+
+
+
+ + {% if S_EDIT_TAG %}{{ lang('CANCEL') }}{% endif %} + + + {{ S_FORM_TOKEN }} +
+
{% INCLUDEJS '@phpbb_topicprefixes/acp_topic_prefixes.js' %} {% INCLUDECSS '@phpbb_topicprefixes/acp_topic_prefixes.css' %} diff --git a/adm/style/acp_topic_prefixes.js b/adm/style/acp_topic_prefixes.js index 5958e7c..cf1d30d 100644 --- a/adm/style/acp_topic_prefixes.js +++ b/adm/style/acp_topic_prefixes.js @@ -1,10 +1,6 @@ (function() { 'use strict'; - document.getElementById('select_forum').addEventListener('change', function() { - this.closest('form').submit(); - }); - phpbb.addAjaxCallback('tp_toggle', function(res) { if (typeof res.success === 'undefined' || !res.success) { return; diff --git a/composer.json b/composer.json index 72dce49..9cd9dd6 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,9 @@ { "name": "phpbb/topicprefixes", "type": "phpbb-extension", - "description": "An official phpBB extension for topic prefixes.", + "description": "An official phpBB extension for administrator-curated topic tags.", "homepage": "https://www.phpbb.com/customise/db/extension/topicprefixes/", - "version": "1.0.2", + "version": "2.0.0", "license": "GPL-2.0-only", "authors": [ { @@ -18,7 +18,7 @@ } ], "require": { - "php": ">=5.4", + "php": ">=7.1", "composer/installers": "^1.0 || ^2.0" }, "require-dev": { @@ -27,7 +27,7 @@ "extra": { "display-name": "phpBB Topic Prefixes", "soft-require": { - "phpbb/phpbb": ">=3.2.0" + "phpbb/phpbb": ">=3.3.0" }, "version-check": { "host": "www.phpbb.com", diff --git a/config/services.yml b/config/services.yml index c9c80ea..0b6c735 100644 --- a/config/services.yml +++ b/config/services.yml @@ -2,42 +2,76 @@ imports: - { resource: tables.yml } services: - phpbb.topicprefixes.listener: - class: phpbb\topicprefixes\event\listener + phpbb.topicprefixes.posting_listener: + class: phpbb\topicprefixes\event\posting_listener arguments: - - '@phpbb.topicprefixes.manager' + - '@phpbb.topicprefixes.tags.manager' + - '@phpbb.topicprefixes.tags.assignment_manager' + - '@phpbb.topicprefixes.tags.renderer' - '@request' - '@language' tags: - { name: event.listener } + phpbb.topicprefixes.viewforum_listener: + class: phpbb\topicprefixes\event\viewforum_listener + arguments: + - '@phpbb.topicprefixes.tags.manager' + - '@phpbb.topicprefixes.tags.assignment_manager' + - '@phpbb.topicprefixes.tags.filter' + - '@phpbb.topicprefixes.tags.renderer' + - '@request' + - '@template' + - '@language' + tags: + - { name: event.listener } + + phpbb.topicprefixes.display_listener: + class: phpbb\topicprefixes\event\display_listener + arguments: + - '@phpbb.topicprefixes.tags.assignment_manager' + - '@phpbb.topicprefixes.tags.renderer' + - '@template' + tags: + - { name: event.listener } + phpbb.topicprefixes.admin_controller: class: phpbb\topicprefixes\controller\admin_controller arguments: - - '@phpbb.topicprefixes.manager' + - '@phpbb.topicprefixes.tags.manager' + - '@phpbb.topicprefixes.tags.renderer' - '@language' - '@log' - '@request' - '@template' - '@user' - - '%core.root_path%' - - '%core.php_ext%' - phpbb.topicprefixes.manager: - class: phpbb\topicprefixes\prefixes\manager + phpbb.topicprefixes.tags.manager: + class: phpbb\topicprefixes\tags\manager arguments: - - '@phpbb.topicprefixes.nestedset_prefixes' + - '@dbal.conn' + - '%phpbb.topicprefixes.tables.topic_prefixes%' + - '%phpbb.topicprefixes.tables.topic_prefixes_forums%' + - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' + - '%tables.forums%' - phpbb.topicprefixes.nestedset_prefixes: - class: phpbb\topicprefixes\prefixes\nestedset_prefixes + phpbb.topicprefixes.tags.assignment_manager: + class: phpbb\topicprefixes\tags\assignment_manager arguments: - '@dbal.conn' - - '@phpbb.topicprefixes.table_lock' + - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' - '%phpbb.topicprefixes.tables.topic_prefixes%' - phpbb.topicprefixes.table_lock: - class: phpbb\lock\db + phpbb.topicprefixes.tags.renderer: + class: phpbb\topicprefixes\tags\renderer + arguments: + - '%core.root_path%' + - '%core.php_ext%' + + phpbb.topicprefixes.tags.filter: + class: phpbb\topicprefixes\tags\filter arguments: - - topicprefixes.table_lock.topic_prefixes_table - - '@config' - '@dbal.conn' + - '@content.visibility' + - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' + - '%tables.topics%' diff --git a/config/tables.yml b/config/tables.yml index 1385ba3..5820b1b 100644 --- a/config/tables.yml +++ b/config/tables.yml @@ -1,2 +1,4 @@ parameters: phpbb.topicprefixes.tables.topic_prefixes: '%core.table_prefix%topic_prefixes' + phpbb.topicprefixes.tables.topic_prefixes_forums: '%core.table_prefix%topic_prefixes_forums' + phpbb.topicprefixes.tables.topic_prefixes_topics: '%core.table_prefix%topic_prefixes_topics' diff --git a/controller/admin_controller.php b/controller/admin_controller.php index d4c1fa5..9764777 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -15,240 +15,242 @@ use phpbb\log\log; use phpbb\request\request; use phpbb\template\template; -use phpbb\topicprefixes\prefixes\manager; +use phpbb\topicprefixes\tags\manager; +use phpbb\topicprefixes\tags\renderer; use phpbb\user; /** - * Class admin_controller + * ACP topic tag management. */ class admin_controller { - /** @var manager Topic prefixes manager object */ + /** @var manager Topic tag manager */ protected $manager; - /** @var language phpBB language object */ + /** @var renderer Topic tag renderer */ + protected $renderer; + + /** @var language Language object */ protected $language; - /** @var log phpBB log object */ + /** @var log Log object */ protected $log; - /** @var request phpBB request object */ + /** @var request Request object */ protected $request; - /** @var template phpBB template object */ + /** @var template Template object */ protected $template; - /** @var user phpBB user object */ + /** @var user User object */ protected $user; - /** @var string phpBB root path */ - protected $root_path; - - /** @var string PHP extension */ - protected $php_ext; - - /** @var string Form key used for form validation */ - protected $form_key; - - /** @var int Forum identifier */ - protected $forum_id; + /** @var string Form key */ + protected $form_key = 'acp_topic_tags'; - /** @var string Custom form action */ - protected $u_action; + /** @var string Module action URL */ + protected $u_action = ''; /** - * Constructor + * Constructor. * - * @param manager $manager Topic prefixes manager object - * @param language $language phpBB language object - * @param log $log phpBB log object - * @param request $request phpBB request object - * @param template $template phpBB template object - * @param user $user phpBB user object - * @param string $phpbb_root_path phpBB root path - * @param string $phpEx PHP extension + * @param manager $manager Topic tag manager + * @param renderer $renderer Topic tag renderer + * @param language $language Language object + * @param log $log Log object + * @param request $request Request object + * @param template $template Template object + * @param user $user User object */ - public function __construct(manager $manager, language $language, log $log, request $request, template $template, user $user, $phpbb_root_path, $phpEx) + public function __construct(manager $manager, renderer $renderer, language $language, log $log, request $request, template $template, user $user) { $this->manager = $manager; + $this->renderer = $renderer; $this->language = $language; $this->log = $log; $this->request = $request; $this->template = $template; $this->user = $user; - $this->root_path = $phpbb_root_path; - $this->php_ext = $phpEx; } /** - * Main handler, called by the ACP module + * Handle ACP actions and display tag settings. * * @return void */ - public function main() + public function main(): void { - $this->form_key = 'acp_topic_prefixes'; add_form_key($this->form_key); - $action = $this->request->variable('action', ''); - $prefix_id = $this->request->variable('prefix_id', 0); - $this->set_forum_id($this->request->variable('forum_id', 0)); + $tag_id = $this->request->variable('tag_id', 0); + $editing = false; switch ($action) { - case 'add': - $this->add_prefix(); + case 'save': + $this->save_tag($tag_id); break; case 'edit': + $editing = $this->manager->get_tag($tag_id); + if (!$editing) + { + $this->trigger_message('TOPIC_TAG_NOT_FOUND', E_USER_WARNING); + } + break; + case 'delete': - $this->{$action . '_prefix'}($prefix_id); + $this->delete_tag($tag_id); + break; + + case 'toggle': + $this->toggle_tag($tag_id); break; case 'move_up': case 'move_down': - $this->move_prefix($prefix_id, str_replace('move_', '', $action)); + $this->move_tag($tag_id, str_replace('move_', '', $action)); break; } - $this->display_settings(); + $this->display_settings($editing); } /** - * Display topic prefix settings + * Assign tag list and editor template variables. * + * @param array|false $editing Tag being edited * @return void */ - public function display_settings() + public function display_settings($editing = false): void { - foreach ($this->manager->get_prefixes($this->forum_id) as $prefix) + $forum_names = $this->manager->get_forum_names_by_tag(); + foreach ($this->manager->get_tags() as $tag) { - $this->template->assign_block_vars('prefixes', [ - 'PREFIX_TAG' => $prefix['prefix_tag'], - 'PREFIX_ENABLED' => (int) $prefix['prefix_enabled'], - 'U_EDIT' => "{$this->u_action}&action=edit&prefix_id=" . $prefix['prefix_id'] . '&forum_id=' . $this->forum_id . '&hash=' . generate_link_hash('edit' . $prefix['prefix_id']), - 'U_DELETE' => "{$this->u_action}&action=delete&prefix_id=" . $prefix['prefix_id'] . '&forum_id=' . $this->forum_id, - 'U_MOVE_UP' => "{$this->u_action}&action=move_up&prefix_id=" . $prefix['prefix_id'] . '&forum_id=' . $this->forum_id . '&hash=' . generate_link_hash('up' . $prefix['prefix_id']), - 'U_MOVE_DOWN' => "{$this->u_action}&action=move_down&prefix_id=" . $prefix['prefix_id'] . '&forum_id=' . $this->forum_id . '&hash=' . generate_link_hash('down' . $prefix['prefix_id']), + $tag_id = (int) $tag['prefix_id']; + $this->template->assign_block_vars('tags', [ + 'TAG_ID' => $tag_id, + 'TAG_NAME' => $tag['prefix_tag'], + 'TAG_COLOR' => '#' . $tag['prefix_color'], + 'TAG_TEXT_COLOR' => $this->renderer->contrast_color($tag['prefix_color']), + 'TAG_ENABLED' => (bool) $tag['prefix_enabled'], + 'FORUM_NAMES' => $forum_names[$tag_id] ?? [], + 'U_EDIT' => $this->u_action . '&action=edit&tag_id=' . $tag_id, + 'U_DELETE' => $this->u_action . '&action=delete&tag_id=' . $tag_id, + 'U_TOGGLE' => $this->u_action . '&action=toggle&tag_id=' . $tag_id . '&hash=' . generate_link_hash('toggle' . $tag_id), + 'U_MOVE_UP' => $this->u_action . '&action=move_up&tag_id=' . $tag_id . '&hash=' . generate_link_hash('up' . $tag_id), + 'U_MOVE_DOWN' => $this->u_action . '&action=move_down&tag_id=' . $tag_id . '&hash=' . generate_link_hash('down' . $tag_id), ]); } + $editing = $editing ?: [ + 'prefix_id' => 0, + 'prefix_tag' => '', + 'prefix_color' => manager::DEFAULT_COLOR, + 'prefix_enabled' => 1, + 'forum_ids' => [], + ]; $this->template->assign_vars([ - 'S_FORUM_OPTIONS' => make_forum_select($this->forum_id, false, false, true), - 'FORUM_ID' => $this->forum_id, - 'U_ACTION' => $this->u_action, + 'U_ACTION' => $this->u_action, + 'S_EDIT_TAG' => !empty($editing['prefix_id']), + 'TAG_ID' => (int) $editing['prefix_id'], + 'TAG_NAME' => $editing['prefix_tag'], + 'TAG_COLOR' => '#' . $editing['prefix_color'], + 'TAG_ENABLED' => (bool) $editing['prefix_enabled'], + 'S_FORUM_OPTIONS' => make_forum_select($editing['forum_ids'], false, false, true), ]); } /** - * Add a prefix + * Create or update one tag. * + * @param int $tag_id Tag identifier, or zero for new tag * @return void */ - public function add_prefix() + public function save_tag(int $tag_id): void { - if ($this->request->is_set_post('submit')) + if (!$this->request->is_set_post('submit') || !check_form_key($this->form_key)) { - if (!check_form_key($this->form_key)) - { - $this->trigger_message('FORM_INVALID', E_USER_WARNING); - } - - $tag = $this->request->variable('prefix_tag', '', true); - $prefix = $this->manager->add_prefix($tag, $this->forum_id); - - if ($prefix) - { - $this->log($prefix['prefix_tag'], 'ACP_LOG_PREFIX_ADDED'); - } + $this->trigger_message('FORM_INVALID', E_USER_WARNING); } - } - /** - * Edit a prefix - * - * @param int $prefix_id The prefix identifier to edit - * @return void - */ - public function edit_prefix($prefix_id) - { - if (!$this->check_hash('edit' . $prefix_id)) + $name = $this->request->variable('tag_name', '', true); + $color = $this->request->variable('tag_color', manager::DEFAULT_COLOR); + $enabled = $this->request->variable('tag_enabled', 0); + $forum_ids = $this->request->variable('forum_ids', [0]); + if (trim($name) === '') { - $this->trigger_message('FORM_INVALID', E_USER_WARNING); + $this->trigger_message('TOPIC_TAG_NAME_REQUIRED', E_USER_WARNING); + } + if ($this->manager->normalize_color($color) === false) + { + $this->trigger_message('TOPIC_TAG_COLOR_INVALID', E_USER_WARNING); } - try + if ($tag_id) { - $prefix = $this->manager->get_prefix($prefix_id); - $this->manager->update_prefix(!$prefix ?: $prefix['prefix_id'], !$prefix ? [] : ['prefix_enabled' => !$prefix['prefix_enabled']]); + $tag = $this->manager->update_tag($tag_id, $name, $color, $enabled, $forum_ids); + $message = 'ACP_LOG_TAG_UPDATED'; } - catch (\OutOfBoundsException $e) + else { - $this->trigger_message($e->getMessage(), E_USER_WARNING); + $tag = $this->manager->add_tag($name, $color, $enabled, $forum_ids); + $message = 'ACP_LOG_TAG_ADDED'; } - - if ($this->request->is_ajax()) + if (!$tag) { - $this->send_json_response(true); + $this->trigger_message('TOPIC_TAG_NOT_FOUND', E_USER_WARNING); } + + $this->log($tag['prefix_tag'], $message); + $this->trigger_message('TOPIC_TAG_SAVED'); } /** - * Delete a prefix + * Delete one tag after confirmation. * - * @param int $prefix_id The prefix identifier to delete + * @param int $tag_id Tag identifier * @return void */ - public function delete_prefix($prefix_id) + public function delete_tag(int $tag_id): void { + $tag = $this->manager->get_tag($tag_id); + if (!$tag) + { + $this->trigger_message('TOPIC_TAG_NOT_FOUND', E_USER_WARNING); + } + if (confirm_box(true)) { - try - { - $prefix = $this->manager->get_prefix($prefix_id); - $this->manager->delete_prefix(!$prefix ?: $prefix['prefix_id']); - $this->log($prefix['prefix_tag'], 'ACP_LOG_PREFIX_DELETED'); - } - catch (\OutOfBoundsException $e) - { - $this->trigger_message($e->getMessage(), E_USER_WARNING); - } - - $this->trigger_message('TOPIC_PREFIX_DELETED'); + $this->manager->delete_tag($tag_id); + $this->log($tag['prefix_tag'], 'ACP_LOG_TAG_DELETED'); + $this->trigger_message('TOPIC_TAG_DELETED'); } - confirm_box(false, $this->language->lang('DELETE_TOPIC_PREFIX_CONFIRM'), build_hidden_fields([ - 'mode' => 'manage', - 'action' => 'delete', - 'prefix_id' => $prefix_id, - 'forum_id' => $this->forum_id, + confirm_box(false, $this->language->lang('DELETE_TOPIC_TAG_CONFIRM'), build_hidden_fields([ + 'mode' => 'manage', + 'action' => 'delete', + 'tag_id' => $tag_id, ])); } /** - * Move a prefix up/down + * Toggle one tag's enabled state. * - * @param int $prefix_id The prefix identifier to move - * @param string $direction The direction (up|down) - * @param int $amount The amount of places to move (default: 1) + * @param int $tag_id Tag identifier * @return void */ - public function move_prefix($prefix_id, $direction, $amount = 1) + public function toggle_tag(int $tag_id): void { - if (!$this->check_hash($direction . $prefix_id)) + if (!$this->check_hash('toggle' . $tag_id)) { $this->trigger_message('FORM_INVALID', E_USER_WARNING); } - - try - { - $this->manager->move_prefix($prefix_id, $direction, $amount); - } - catch (\OutOfBoundsException $e) + $tag = $this->manager->get_tag($tag_id); + if (!$tag || !$this->manager->set_enabled($tag_id, !$tag['prefix_enabled'])) { - $this->trigger_message($e->getMessage(), E_USER_WARNING); + $this->trigger_message('TOPIC_TAG_NOT_FOUND', E_USER_WARNING); } - if ($this->request->is_ajax()) { $this->send_json_response(true); @@ -256,95 +258,84 @@ public function move_prefix($prefix_id, $direction, $amount = 1) } /** - * Set u_action + * Move one tag in display order. * - * @param string $u_action Custom form action - * @return admin_controller + * @param int $tag_id Tag identifier + * @param string $direction Move direction + * @return void */ - public function set_u_action($u_action) + public function move_tag(int $tag_id, string $direction): void { - $this->u_action = $u_action; - return $this; + if (!$this->check_hash($direction . $tag_id)) + { + $this->trigger_message('FORM_INVALID', E_USER_WARNING); + } + if (!$this->manager->move_tag($tag_id, $direction)) + { + $this->trigger_message('TOPIC_TAG_NOT_FOUND', E_USER_WARNING); + } + if ($this->request->is_ajax()) + { + $this->send_json_response(true); + } } /** - * Set forum ID + * Set ACP module action URL. * - * @param int $forum_id Forum identifier + * @param string $u_action Module action URL * @return admin_controller */ - public function set_forum_id($forum_id) + public function set_u_action(string $u_action): self { - $this->forum_id = $forum_id; + $this->u_action = $u_action; return $this; } /** - * Check link hash helper + * Validate action link hash. * - * @param string $hash A hashed string - * @return bool True if hash matches, false if not + * @param string $hash Expected hash key + * @return bool */ - protected function check_hash($hash) + protected function check_hash(string $hash): bool { return check_link_hash($this->request->variable('hash', ''), $hash); } /** - * Trigger a message and back link for error/success dialogs + * Display localized ACP message and return link. * - * @param string $message A language key - * @param int $error Error type constant, optional + * @param string $message Language key + * @param int $error PHP user error level * @return void */ - protected function trigger_message($message = '', $error = E_USER_NOTICE) + protected function trigger_message(string $message, int $error = E_USER_NOTICE): void { - trigger_error($this->language->lang($message) . adm_back_link("{$this->u_action}&forum_id={$this->forum_id}"), $error); + trigger_error($this->language->lang($message) . adm_back_link($this->u_action), $error); } /** - * Helper for logging topic prefix admin actions + * Add ACP log entry. * - * @param string $tag The topic prefix tag - * @param string $message The log action language key + * @param string $tag Tag text + * @param string $message Log language key * @return void */ - protected function log($tag, $message) - { - $forum_data = $this->get_forum_info($this->forum_id); - - $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $message, time(), [$tag, $forum_data['forum_name']]); - } - - /** - * Get a forum's information - * - * @param int $forum_id - * @return mixed Array with the current row, false, if the row does not exist - */ - protected function get_forum_info($forum_id) + protected function log(string $tag, string $message): void { - if (!class_exists('acp_forums')) - { - include $this->root_path . 'includes/acp/acp_forums.' . $this->php_ext; - } - - $acp_forums = new \acp_forums(); - - return $acp_forums->get_forum_info($forum_id); + $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $message, time(), [$tag]); } /** - * Send a JSON response + * Send AJAX action result. * - * @param bool $content The content of the JSON response (true|false) - * @access protected + * @param bool $content Action status + * @return void */ - protected function send_json_response($content) + protected function send_json_response(bool $content): void { - $json_response = new json_response; - $json_response->send([ - 'success' => (bool) $content, - ]); + $response = new json_response; + $response->send(['success' => $content]); } } diff --git a/event/display_listener.php b/event/display_listener.php new file mode 100644 index 0000000..b72cbdf --- /dev/null +++ b/event/display_listener.php @@ -0,0 +1,105 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\event; + +use phpbb\topicprefixes\tags\assignment_manager; +use phpbb\topicprefixes\tags\renderer; +use phpbb\template\template; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Display topic tags on viewtopic and search result pages. + */ +class display_listener implements EventSubscriberInterface +{ + /** @var assignment_manager Topic/tag assignment manager */ + protected $assignments; + + /** @var renderer Topic tag renderer */ + protected $renderer; + + /** @var template Template object */ + protected $template; + + /** @var array Tags grouped by search result topic */ + protected $search_tags = []; + + /** + * Constructor. + * + * @param assignment_manager $assignments Topic/tag assignment manager + * @param renderer $renderer Topic tag renderer + * @param template $template Template object + */ + public function __construct(assignment_manager $assignments, renderer $renderer, template $template) + { + $this->assignments = $assignments; + $this->renderer = $renderer; + $this->template = $template; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + 'core.viewtopic_assign_template_vars_before' => 'add_viewtopic_tags', + 'core.search_modify_rowset' => 'load_search_tags', + 'core.search_modify_tpl_ary' => 'add_search_tags', + ]; + } + + /** + * Assign tags for current viewtopic heading. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_viewtopic_tags($event): void + { + $topic_id = (int) $event['topic_id']; + $tags = $this->assignments->get_tags_for_topics([$topic_id]); + $topic_tags = $tags[$topic_id] ?? []; + $this->template->assign_var('TOPIC_TAGS', $this->renderer->render($topic_tags, (int) $event['forum_id'])); + } + + /** + * Batch-load tags for search result topics. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function load_search_tags($event): void + { + $topic_ids = []; + foreach ($event['rowset'] as $row) + { + $topic_ids[] = (int) $row['topic_id']; + } + $this->search_tags = $this->assignments->get_tags_for_topics($topic_ids); + } + + /** + * Add tag badge data to one search result row. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_search_tags($event): void + { + $topic_id = (int) $event['row']['topic_id']; + $tags = $this->search_tags[$topic_id] ?? []; + $tpl = $event['tpl_ary']; + $tpl['TOPIC_TAGS'] = $this->renderer->render($tags, (int) $event['row']['forum_id']); + $event['tpl_ary'] = $tpl; + } +} diff --git a/event/listener.php b/event/listener.php deleted file mode 100644 index 3c40b5b..0000000 --- a/event/listener.php +++ /dev/null @@ -1,203 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\event; - -use phpbb\language\language; -use phpbb\request\request; -use phpbb\topicprefixes\prefixes\manager; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; - -/** - * Event listener - */ -class listener implements EventSubscriberInterface -{ - /** - * @var manager Topic prefixes manager - */ - protected $manager; - - /** - * @var request Request object - */ - protected $request; - - /** - * @var language Language object - */ - protected $language; - - /** - * @var array An array of topic prefixes - */ - protected $prefixes; - - /** - * @inheritdoc - */ - public static function getSubscribedEvents() - { - return [ - 'core.posting_modify_template_vars' => 'add_to_posting_form', - 'core.posting_modify_submit_post_before' => 'submit_prefix_data', - 'core.submit_post_modify_sql_data' => 'save_prefix_to_topic', - ]; - } - - /** - * Listener constructor - * - * @param manager $manager Topic prefixes manager - * @param request $request Request object - * @param language $language Language object - */ - public function __construct(manager $manager, request $request, language $language) - { - $this->manager = $manager; - $this->request = $request; - $this->language = $language; - } - - /** - * Update the posting page form with template vars - * for the topic prefix drop-down menu. - * - * @param \phpbb\event\data $event Event data object - * @return void - */ - public function add_to_posting_form($event) - { - if (!$this->is_new_topic($event)) - { - return; - } - - $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); - - // Get prefixes for the current forum - $this->prefixes = $this->manager->get_active_prefixes($event['forum_id']); - - // Get the current prefix selected - $selected = $this->get_selected_prefix($event); - - $event['page_data'] = array_merge($event['page_data'], [ - 'PREFIXES' => $this->prefixes, - 'SELECTED_PREFIX' => array_key_exists($selected, $this->prefixes) ? $this->prefixes[$selected]['prefix_tag'] : '', - ]); - } - - /** - * Prepare topic prefix data for post submission - * - * @param \phpbb\event\data $event Event data object - * @return void - */ - public function submit_prefix_data($event) - { - $selected = $this->request->variable('topic_prefix', 0); - - // Get data for the prefix selected by the user - $prefix = $this->manager->get_prefix($selected); - - // First, add the topic prefix id to the data to be stored with the db - $data = $event['data']; - $data['topic_prefix_id'] = $prefix ? (int) $prefix['prefix_id'] : 0; - $event['data'] = $data; - - // Next, prepend the topic prefix to the subject (if necessary) - if (isset($prefix['prefix_tag'])) - { - $post_data = $event['post_data']; - $post_data['post_subject'] = $this->manager->prepend_prefix($prefix['prefix_tag'], $post_data['post_subject']); - $event['post_data'] = $post_data; - } - } - - /** - * Save the topic prefix id with the associated topic - * - * @param \phpbb\event\data $event Event data object - * @return void - */ - public function save_prefix_to_topic($event) - { - if (!array_key_exists('topic_prefix_id', $event['data']) || !in_array($event['post_mode'], ['edit_first_post', 'edit_topic', 'post'], true)) - { - return; - } - - $sql_data = $event['sql_data']; - $sql_data[TOPICS_TABLE]['sql']['topic_prefix_id'] = $event['data']['topic_prefix_id']; - $event['sql_data'] = $sql_data; - } - - /** - * Is a new topic being posted/edited? - * - * @param \phpbb\event\data $event Event data object - * @return bool Return true if starting a new post or editing the first post, false otherwise - */ - protected function is_new_topic($event) - { - return ($event['mode'] === 'post' || ($event['mode'] === 'edit' && $event['post_data']['topic_first_post_id'] == $event['post_data']['post_id'])); - } - - /** - * Get the current prefix for the selection menu - * - * @param \phpbb\event\data $event Event data object - * @return int Identifier for the selected prefix - */ - protected function get_selected_prefix($event) - { - // Get the prefix from the select menu - $prefix_id = $this->request->variable('topic_prefix', 0); - - // If we are in preview mode, send back the prefix from the form - if (!empty($event['preview']) && $event['mode'] !== 'edit') - { - return $prefix_id; - } - - // If no prefix was selected, get one if it already exists (ie: editing a post) - if (!$prefix_id && !empty($event['post_data']['topic_prefix_id'])) - { - $prefix_id = (int) $event['post_data']['topic_prefix_id']; - } - - // If still no prefix was identified, look in existing topic title (ie: editing a post) - if (!$prefix_id && !empty($event['post_data']['topic_title'])) - { - $prefix_id = $this->find_prefix_in_title($event['post_data']['topic_title']); - } - - return $prefix_id; - } - - /** - * Find an active topic prefix in the topic title - * - * @param string $title The post title - * @return int Identifier for the found prefix - */ - protected function find_prefix_in_title($title) - { - foreach ($this->prefixes as $prefix_id => $prefix_data) - { - if (strpos($title, $prefix_data['prefix_tag']) === 0) - { - return (int) $prefix_id; - } - } - - return 0; - } -} diff --git a/event/posting_listener.php b/event/posting_listener.php new file mode 100644 index 0000000..d0c3aed --- /dev/null +++ b/event/posting_listener.php @@ -0,0 +1,209 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\event; + +use phpbb\language\language; +use phpbb\request\request; +use phpbb\topicprefixes\tags\assignment_manager; +use phpbb\topicprefixes\tags\manager; +use phpbb\topicprefixes\tags\renderer; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Integrate topic tag selection with new topics and first-post edits. + */ +class posting_listener implements EventSubscriberInterface +{ + /** @var manager Topic tag manager */ + protected $manager; + + /** @var assignment_manager Topic/tag assignment manager */ + protected $assignments; + + /** @var renderer Topic tag renderer */ + protected $renderer; + + /** @var request Request object */ + protected $request; + + /** @var language Language object */ + protected $language; + + /** @var array|null Valid submitted tag identifiers */ + protected $submitted_tag_ids; + + /** + * Constructor. + * + * @param manager $manager Topic tag manager + * @param assignment_manager $assignments Topic/tag assignment manager + * @param renderer $renderer Topic tag renderer + * @param request $request Request object + * @param language $language Language object + */ + public function __construct(manager $manager, assignment_manager $assignments, renderer $renderer, request $request, language $language) + { + $this->manager = $manager; + $this->assignments = $assignments; + $this->renderer = $renderer; + $this->request = $request; + $this->language = $language; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + 'core.posting_modify_submission_errors' => 'validate_submission', + 'core.posting_modify_template_vars' => 'add_to_posting_form', + 'core.posting_modify_submit_post_before' => 'capture_submission', + 'core.submit_post_end' => 'save_assignments', + ]; + } + + /** + * Reject disabled, unavailable, or unknown submitted tag IDs. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function validate_submission($event): void + { + if (empty($event['submit']) || !$this->is_topic_form($event['mode'], $event['post_data'])) + { + return; + } + if (!$this->request->is_set_post('topic_tags_present') && !$this->request->is_set_post('topic_tags')) + { + return; + } + + $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); + $submitted = $this->get_submitted_tag_ids(); + $valid = array_keys($this->manager->get_assignable_tags($event['forum_id'], $submitted)); + sort($submitted, SORT_NUMERIC); + sort($valid, SORT_NUMERIC); + if ($submitted !== $valid) + { + $error = $event['error']; + $error[] = $this->language->lang('TOPIC_TAGS_INVALID'); + $event['error'] = $error; + return; + } + + $this->submitted_tag_ids = $valid; + } + + /** + * Add accessible tag checkboxes to eligible posting forms. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_to_posting_form($event): void + { + if (!$this->is_topic_form($event['mode'], $event['post_data'])) + { + return; + } + + $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); + $available = $this->manager->get_available_tags($event['forum_id']); + if (!$available) + { + return; + } + + if ($this->request->is_set_post('topic_tags_present')) + { + $selected = $this->get_submitted_tag_ids(); + } + else if ($event['mode'] === 'edit') + { + $selected = $this->assignments->get_topic_tag_ids($event['topic_id']); + } + else + { + $selected = []; + } + + $page_data = $event['page_data']; + $page_data['TOPIC_TAGS'] = $this->renderer->render($available, 0, $selected); + $page_data['S_TOPIC_TAGS'] = true; + $event['page_data'] = $page_data; + } + + /** + * Capture valid tag IDs before phpBB submits topic data. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function capture_submission($event): void + { + if (!$this->is_topic_form($event['mode'], $event['post_data'])) + { + return; + } + if (!$this->request->is_set_post('topic_tags_present') && !$this->request->is_set_post('topic_tags')) + { + return; + } + + if ($this->submitted_tag_ids === null) + { + $submitted = $this->get_submitted_tag_ids(); + $this->submitted_tag_ids = array_keys($this->manager->get_assignable_tags($event['forum_id'], $submitted)); + } + } + + /** + * Save captured relationships after topic submission. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function save_assignments($event): void + { + if ($this->submitted_tag_ids === null || empty($event['data']['topic_id'])) + { + return; + } + + $this->assignments->set_topic_tags((int) $event['data']['topic_id'], $this->submitted_tag_ids); + $this->submitted_tag_ids = null; + } + + /** + * Check whether form creates a topic or edits its first post. + * + * @param string $mode Posting mode + * @param array $post_data Posting data + * @return bool + */ + protected function is_topic_form(string $mode, array $post_data): bool + { + return $mode === 'post' || ($mode === 'edit' && isset($post_data['post_id'], $post_data['topic_first_post_id']) && (int) $post_data['post_id'] === (int) $post_data['topic_first_post_id']); + } + + /** + * Normalize submitted tag identifiers. + * + * @return array Tag identifiers + */ + protected function get_submitted_tag_ids(): array + { + $ids = $this->request->variable('topic_tags', [0]); + return array_values(array_unique(array_filter(array_map('intval', $ids)))); + } +} diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php new file mode 100644 index 0000000..0f3e49e --- /dev/null +++ b/event/viewforum_listener.php @@ -0,0 +1,255 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\event; + +use phpbb\language\language; +use phpbb\request\request; +use phpbb\template\template; +use phpbb\topicprefixes\tags\assignment_manager; +use phpbb\topicprefixes\tags\filter; +use phpbb\topicprefixes\tags\manager; +use phpbb\topicprefixes\tags\renderer; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Integrate tag filtering and badges with forum topic lists. + */ +class viewforum_listener implements EventSubscriberInterface +{ + /** @var manager Topic tag manager */ + protected $manager; + + /** @var assignment_manager Topic/tag assignment manager */ + protected $assignments; + + /** @var filter Topic tag SQL filter */ + protected $filter; + + /** @var renderer Topic tag renderer */ + protected $renderer; + + /** @var request Request object */ + protected $request; + + /** @var template Template object */ + protected $template; + + /** @var language Language object */ + protected $language; + + /** @var int Forum identifier */ + protected $forum_id = 0; + + /** @var array Selected tag identifiers */ + protected $selected_ids = []; + + /** @var array Current topic sort parameters */ + protected $sort_params = []; + + /** @var array Tags grouped by topic identifier */ + protected $topic_tags = []; + + /** + * Constructor. + * + * @param manager $manager Topic tag manager + * @param assignment_manager $assignments Topic/tag assignment manager + * @param filter $filter Topic tag SQL filter + * @param renderer $renderer Topic tag renderer + * @param request $request Request object + * @param template $template Template object + * @param language $language Language object + */ + public function __construct(manager $manager, assignment_manager $assignments, filter $filter, renderer $renderer, request $request, template $template, language $language) + { + $this->manager = $manager; + $this->assignments = $assignments; + $this->filter = $filter; + $this->renderer = $renderer; + $this->request = $request; + $this->template = $template; + $this->language = $language; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + 'core.viewforum_get_topic_data' => 'configure_filter', + 'core.viewforum_get_announcement_topic_ids_data' => 'filter_announcements', + 'core.viewforum_get_topic_ids_data' => 'filter_topic_ids', + 'core.viewforum_modify_topics_data' => 'load_topic_tags', + 'core.viewforum_modify_topicrow' => 'add_topic_tags', + 'core.pagination_generate_page_link' => 'preserve_pagination_filter', + ]; + } + + /** + * Read and validate filters before phpBB builds topic queries. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function configure_filter($event): void + { + $this->forum_id = (int) $event['forum_id']; + $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); + + $available = $this->manager->get_available_tags($this->forum_id); + $filterable = $this->manager->get_available_tags($this->forum_id, false); + $this->selected_ids = array_values(array_intersect($this->get_requested_ids(), array_keys($filterable))); + + // Keep disabled selected tags visible so users can remove active filters. + foreach ($this->selected_ids as $tag_id) + { + if (!isset($available[$tag_id])) + { + $available[$tag_id] = $filterable[$tag_id]; + } + } + uasort($available, [$this, 'compare_tags']); + + $this->sort_params = [ + 'st' => (int) $event['sort_days'], + 'sk' => $event['sort_key'], + 'sd' => $event['sort_dir'], + ]; + + if ($this->selected_ids) + { + $event['topics_count'] = $this->filter->count_topics($this->forum_id, $this->selected_ids, $event['sort_days']); + } + + $this->template->assign_vars([ + 'S_TOPIC_TAG_FILTERS' => !empty($available), + 'TOPIC_TAG_FILTERS' => $this->renderer->render($available, $this->forum_id, $this->selected_ids, $this->sort_params, true), + 'U_CLEAR_TOPIC_TAG_FILTERS' => $this->renderer->filter_url($this->forum_id, [], $this->sort_params), + 'S_TOPIC_TAG_FILTERED' => !empty($this->selected_ids), + 'S_FORUM_ACTION' => $this->renderer->filter_url($this->forum_id, $this->selected_ids), + ]); + } + + /** + * Apply selected tags to announcement query. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function filter_announcements($event): void + { + if (!$this->selected_ids) + { + return; + } + + $sql = $event['sql_ary']; + $sql['WHERE'] = '(' . $sql['WHERE'] . ') AND ' . $this->filter->condition('t', $this->selected_ids); + $event['sql_ary'] = $sql; + } + + /** + * Apply selected tags to regular topic ID query. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function filter_topic_ids($event): void + { + if (!$this->selected_ids) + { + return; + } + + $sql = $event['sql_ary']; + $sql['WHERE'] .= ' AND ' . $this->filter->condition('t', $this->selected_ids); + $event['sql_ary'] = $sql; + } + + /** + * Batch-load tags for displayed topic rows. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function load_topic_tags($event): void + { + $topic_ids = []; + foreach ($event['rowset'] as $row) + { + $topic_ids[] = (int) $row['topic_id']; + } + $this->topic_tags = $this->assignments->get_tags_for_topics($topic_ids); + } + + /** + * Add tag badge data to one topic row. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_topic_tags($event): void + { + $topic_id = (int) $event['row']['topic_id']; + $tags = $this->topic_tags[$topic_id] ?? []; + $topic_row = $event['topic_row']; + $topic_row['TOPIC_TAGS'] = $this->renderer->render($tags, $this->forum_id, $this->selected_ids, $this->sort_params); + $event['topic_row'] = $topic_row; + } + + /** + * Preserve selected tags in phpBB pagination URLs. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function preserve_pagination_filter($event): void + { + if (!$this->selected_ids || !is_string($event['base_url']) || strpos($event['base_url'], 'viewforum.') === false || strpos($event['base_url'], 'tags=') !== false) + { + return; + } + + $delimiter = strpos($event['base_url'], '?') === false ? '?' : '&'; + $event['base_url'] .= $delimiter . 'tags=' . implode(',', $this->selected_ids); + } + + /** + * Parse comma-separated tag identifiers from request. + * + * @return array Tag identifiers + */ + protected function get_requested_ids(): array + { + $value = trim($this->request->variable('tags', '')); + if ($value === '' || !preg_match('/^\d+(?:,\d+)*$/D', $value)) + { + return []; + } + + return array_values(array_unique(array_filter(array_map('intval', explode(',', $value))))); + } + + /** + * Sort tags by administrator order then identifier. + * + * @param array $left Left tag + * @param array $right Right tag + * @return int Comparison result + */ + protected function compare_tags(array $left, array $right): int + { + $order = (int) $left['prefix_order'] <=> (int) $right['prefix_order']; + + return $order ?: (int) $left['prefix_id'] <=> (int) $right['prefix_id']; + } +} diff --git a/ext.php b/ext.php index 9ca695e..f4f3a67 100644 --- a/ext.php +++ b/ext.php @@ -25,12 +25,12 @@ class ext extends \phpbb\extension\base * The current phpBB version should meet or exceed * the minimum version required by this extension: * - * Requires phpBB 3.2.0 and PHP 5.4 + * Requires phpBB 3.3.0 and PHP 7.1 * * @return bool */ public function is_enableable() { - return phpbb_version_compare(PHPBB_VERSION, '3.2.0', '>=') && PHP_VERSION_ID >= 50400; + return phpbb_version_compare(PHPBB_VERSION, '3.3.0', '>=') && PHP_VERSION_ID >= 70100; } } diff --git a/language/en/acp_topic_prefixes.php b/language/en/acp_topic_prefixes.php index 524f26c..71eb4d9 100644 --- a/language/en/acp_topic_prefixes.php +++ b/language/en/acp_topic_prefixes.php @@ -8,9 +8,6 @@ * */ -/** - * DO NOT CHANGE - */ if (!defined('IN_PHPBB')) { exit; @@ -18,26 +15,32 @@ if (empty($lang) || !is_array($lang)) { - $lang = array(); + $lang = []; } -$lang = array_merge($lang, array( - 'TOPIC_PREFIXES' => 'Topic prefixes', - 'TOPIC_PREFIXES_EXPLAIN' => 'From this page you can manage topic prefixes for forums.', - - 'TOPIC_PREFIX_TAG' => 'Prefix Tag', - 'TOPIC_PREFIX_ENABLED' => 'Prefix Enabled', - 'TOPIC_PREFIXES_EMPTY' => 'There are no prefixes for this forum.', - 'TOPIC_PREFIX_PLACEHOLDER' => '[My Prefix]', - - 'CREATE_TOPIC_PREFIX' => 'Create a new topic prefix', - 'DELETE_TOPIC_PREFIX_CONFIRM' => 'Are you sure you want to delete the topic prefix?', - 'TOPIC_PREFIX_DELETED' => 'The topic prefix has been deleted.', - - 'TOPIC_PREFIX_TOGGLE_STATE' => 'Click to enable or disable this topic prefix', +$lang = array_merge($lang, [ + 'TOPIC_TAGS' => 'Topic tags', + 'TOPIC_TAGS_EXPLAIN' => 'Create administrator-curated topic tags, set their display order, and choose where each tag is available.', + 'TOPIC_TAG' => 'Tag', + 'TOPIC_TAG_TEXT' => 'Tag text', + 'TOPIC_TAG_COLOR' => 'Badge color', + 'TOPIC_TAG_COLOR_EXPLAIN' => 'Choose the badge background color. Badge text color is selected automatically for contrast.', + 'TOPIC_TAG_ENABLED' => 'Enabled', + 'TOPIC_TAG_FORUMS' => 'Available forums', + 'TOPIC_TAG_FORUMS_EXPLAIN' => 'Select one or more forums. Use Ctrl/Cmd to select multiple forums.', + 'TOPIC_TAG_NO_FORUMS' => 'No forums', + 'TOPIC_TAGS_EMPTY' => 'No topic tags have been created.', + 'CREATE_TOPIC_TAG' => 'Create topic tag', + 'EDIT_TOPIC_TAG' => 'Edit topic tag', + 'DELETE_TOPIC_TAG_CONFIRM' => 'Delete this topic tag? Existing topic assignments will also be removed.', + 'TOPIC_TAG_DELETED' => 'Topic tag deleted.', + 'TOPIC_TAG_SAVED' => 'Topic tag saved.', + 'TOPIC_TAG_TOGGLE_STATE' => 'Enable or disable this topic tag', + 'TOPIC_TAG_NAME_REQUIRED' => 'Tag text is required.', + 'TOPIC_TAG_COLOR_INVALID' => 'Badge color must be a six-digit hexadecimal color.', + 'TOPIC_TAG_NOT_FOUND' => 'Requested topic tag does not exist.', - // Nested set exception messages - 'TOPIC_PREFIXES_LOCK_FAILED_ACQUIRE' => 'Topic prefixes extension failed to acquire a lock on the table.', - 'TOPIC_PREFIXES_INVALID_ITEM' => 'The requested topic prefix does not exist.', - 'TOPIC_PREFIXES_INVALID_PARENT' => 'The requested topic prefix has no parent.', -)); + // Legacy keys remain for migration/module lineage and third-party language packs. + 'TOPIC_PREFIXES' => 'Topic tags', + 'TOPIC_PREFIXES_EXPLAIN' => 'Manage administrator-curated topic tags.', +]); diff --git a/language/en/info_acp_topic_prefixes.php b/language/en/info_acp_topic_prefixes.php index efc4dba..2989d81 100644 --- a/language/en/info_acp_topic_prefixes.php +++ b/language/en/info_acp_topic_prefixes.php @@ -8,9 +8,6 @@ * */ -/** - * DO NOT CHANGE - */ if (!defined('IN_PHPBB')) { exit; @@ -18,14 +15,13 @@ if (empty($lang) || !is_array($lang)) { - $lang = array(); + $lang = []; } -$lang = array_merge($lang, array( - 'ACP_TOPIC_PREFIXES' => 'Topic prefixes', - 'ACP_MANAGE_PREFIXES' => 'Manage prefixes', - - // ACP Log messages - 'ACP_LOG_PREFIX_ADDED' => 'Created new topic prefix
Β» %1$s in forum: %2$s', - 'ACP_LOG_PREFIX_DELETED' => 'Deleted topic prefix
Β» %1$s in forum: %2$s', -)); +$lang = array_merge($lang, [ + 'ACP_TOPIC_PREFIXES' => 'Topic tags', + 'ACP_MANAGE_PREFIXES' => 'Manage tags', + 'ACP_LOG_TAG_ADDED' => 'Created topic tag
Β» %s', + 'ACP_LOG_TAG_UPDATED' => 'Updated topic tag
Β» %s', + 'ACP_LOG_TAG_DELETED' => 'Deleted topic tag
Β» %s', +]); diff --git a/language/en/topic_prefixes.php b/language/en/topic_prefixes.php index f2473cb..cf2cba4 100644 --- a/language/en/topic_prefixes.php +++ b/language/en/topic_prefixes.php @@ -8,9 +8,6 @@ * */ -/** - * DO NOT CHANGE - */ if (!defined('IN_PHPBB')) { exit; @@ -18,9 +15,12 @@ if (empty($lang) || !is_array($lang)) { - $lang = array(); + $lang = []; } -$lang = array_merge($lang, array( - 'TOPIC_PREFIX' => 'Topic prefix', -)); +$lang = array_merge($lang, [ + 'TOPIC_TAGS' => 'Topic tags', + 'TOPIC_TAGS_FILTER' => 'Filter by tag', + 'TOPIC_TAGS_CLEAR_FILTERS' => 'Clear tag filters', + 'TOPIC_TAGS_INVALID' => 'One or more selected topic tags are disabled or unavailable in this forum.', +]); diff --git a/migrations/v200_cleanup.php b/migrations/v200_cleanup.php new file mode 100644 index 0000000..1810f27 --- /dev/null +++ b/migrations/v200_cleanup.php @@ -0,0 +1,75 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\migrations; + +/** + * Remove obsolete single-prefix schema after relational data migration. + */ +class v200_cleanup extends \phpbb\db\migration\migration +{ + /** + * {@inheritdoc} + */ + public function effectively_installed() + { + return !$this->db_tools->sql_column_exists($this->table_prefix . 'topics', 'topic_prefix_id') + && !$this->db_tools->sql_column_exists($this->table_prefix . 'topic_prefixes', 'forum_id') + && !$this->db_tools->sql_column_exists($this->table_prefix . 'topic_prefixes', 'prefix_left_id'); + } + + /** + * {@inheritdoc} + */ + public static function depends_on() + { + return ['\phpbb\topicprefixes\migrations\v200_data']; + } + + /** + * {@inheritdoc} + */ + public function update_schema() + { + return [ + 'drop_columns' => [ + $this->table_prefix . 'topics' => ['topic_prefix_id'], + $this->table_prefix . 'topic_prefixes' => [ + 'prefix_parent_id', + 'prefix_left_id', + 'prefix_right_id', + 'prefix_parents', + 'forum_id', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function revert_schema() + { + return [ + 'add_columns' => [ + $this->table_prefix . 'topics' => [ + 'topic_prefix_id' => ['UINT', 0], + ], + $this->table_prefix . 'topic_prefixes' => [ + 'prefix_parent_id' => ['UINT', 0], + 'prefix_left_id' => ['UINT', 0], + 'prefix_right_id' => ['UINT', 0], + 'prefix_parents' => ['MTEXT_UNI', ''], + 'forum_id' => ['UINT', 0], + ], + ], + ]; + } +} diff --git a/migrations/v200_data.php b/migrations/v200_data.php new file mode 100644 index 0000000..560f2f2 --- /dev/null +++ b/migrations/v200_data.php @@ -0,0 +1,277 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\migrations; + +/** + * Convert legacy title prefixes to relational topic tags. + */ +class v200_data extends \phpbb\db\migration\migration +{ + const DEFAULT_COLOR = '4A76A8'; + const BATCH_SIZE = 500; + + /** + * {@inheritdoc} + */ + public function effectively_installed() + { + return isset($this->config['topicprefixes_tags_migrated']); + } + + /** + * {@inheritdoc} + */ + public static function depends_on() + { + return ['\phpbb\topicprefixes\migrations\v200_schema']; + } + + /** + * {@inheritdoc} + */ + public function update_data() + { + return [ + ['custom', [[$this, 'migrate_legacy_data']]], + ['config.add', ['topicprefixes_tags_migrated', 1]], + ]; + } + + /** + * {@inheritdoc} + */ + public function revert_data() + { + return [ + ['config.remove', ['topicprefixes_tags_migrated']], + ]; + } + + /** + * Migrate legacy definitions, forum availability, and topic assignments. + * + * Topic rows are processed in bounded transactions. An interrupted run can + * safely restart because existing relationships and already-clean titles are + * detected before writes. + * + * @return void + */ + public function migrate_legacy_data(): void + { + $tables = [ + 'tags' => $this->table_prefix . 'topic_prefixes', + 'forums' => $this->table_prefix . 'topic_prefixes_forums', + 'topic_tags' => $this->table_prefix . 'topic_prefixes_topics', + 'topics' => $this->table_prefix . 'topics', + 'posts' => $this->table_prefix . 'posts', + ]; + + $this->migrate_tag_definitions($tables['tags']); + $this->migrate_forum_availability($tables['tags'], $tables['forums']); + + $last_topic_id = 0; + do + { + $topics = $this->get_legacy_topics($tables, $last_topic_id); + if (!$topics) + { + break; + } + + $last_topic_id = (int) end($topics)['topic_id']; + $this->migrate_topic_batch($tables, $topics); + } + while (count($topics) === self::BATCH_SIZE); + } + + /** + * Add default presentation fields to legacy tag definitions. + * + * @param string $tags_table Tag definition table + * @return void + */ + protected function migrate_tag_definitions(string $tags_table): void + { + $sql = 'UPDATE ' . $tags_table . " + SET prefix_color = '" . self::DEFAULT_COLOR . "', + prefix_order = prefix_left_id"; + $this->db->sql_query($sql); + } + + /** + * Copy legacy single-forum values into forum/tag relationships. + * + * @param string $tags_table Tag definition table + * @param string $forums_table Forum/tag map table + * @return void + */ + protected function migrate_forum_availability(string $tags_table, string $forums_table): void + { + $sql = 'SELECT p.forum_id, p.prefix_id + FROM ' . $tags_table . ' p + LEFT JOIN ' . $forums_table . ' pf + ON pf.forum_id = p.forum_id + AND pf.prefix_id = p.prefix_id + WHERE p.forum_id <> 0 + AND pf.prefix_id IS NULL'; + $result = $this->db->sql_query($sql); + $rows = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $rows[] = [ + 'forum_id' => (int) $row['forum_id'], + 'prefix_id' => (int) $row['prefix_id'], + ]; + } + $this->db->sql_freeresult($result); + + if ($rows) + { + $this->db->sql_multi_insert($forums_table, $rows); + } + } + + /** + * Load one bounded set of legacy topics and first-post subjects. + * + * @param array $tables Migration table names + * @param int $last_topic_id Last processed topic identifier + * @return array Legacy topic rows + */ + protected function get_legacy_topics(array $tables, int $last_topic_id): array + { + $sql = 'SELECT t.topic_id, t.topic_title, t.topic_first_post_id, + t.topic_prefix_id, p.prefix_tag, fp.post_subject + FROM ' . $tables['topics'] . ' t + INNER JOIN ' . $tables['tags'] . ' p + ON p.prefix_id = t.topic_prefix_id + LEFT JOIN ' . $tables['posts'] . ' fp + ON fp.post_id = t.topic_first_post_id + WHERE t.topic_prefix_id <> 0 + AND t.topic_id > ' . $last_topic_id . ' + ORDER BY t.topic_id ASC'; + $result = $this->db->sql_query_limit($sql, self::BATCH_SIZE); + $topics = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $topics[] = $row; + } + $this->db->sql_freeresult($result); + + return $topics; + } + + /** + * Migrate one topic batch in a transaction. + * + * @param array $tables Migration table names + * @param array $topics Legacy topic rows + * @return void + */ + protected function migrate_topic_batch(array $tables, array $topics): void + { + $topic_ids = array_map('intval', array_column($topics, 'topic_id')); + $existing = $this->get_existing_assignments($tables['topic_tags'], $topic_ids); + $assignments = []; + $topic_titles = []; + $post_subjects = []; + + foreach ($topics as $topic) + { + $topic_id = (int) $topic['topic_id']; + $tag_id = (int) $topic['topic_prefix_id']; + if (empty($existing[$topic_id][$tag_id])) + { + $assignments[] = ['topic_id' => $topic_id, 'prefix_id' => $tag_id]; + } + + $legacy_text = $topic['prefix_tag'] . ' '; + if ($legacy_text === ' ') + { + continue; + } + + if (strpos($topic['topic_title'], $legacy_text) === 0) + { + $topic_titles[$topic_id] = substr($topic['topic_title'], strlen($legacy_text)); + } + + $post_id = (int) $topic['topic_first_post_id']; + if ($post_id && $topic['post_subject'] !== null && strpos($topic['post_subject'], $legacy_text) === 0) + { + $post_subjects[$post_id] = substr($topic['post_subject'], strlen($legacy_text)); + } + } + + $this->db->sql_transaction('begin'); + if ($assignments) + { + $this->db->sql_multi_insert($tables['topic_tags'], $assignments); + } + $this->bulk_update_text($tables['topics'], 'topic_id', 'topic_title', $topic_titles); + $this->bulk_update_text($tables['posts'], 'post_id', 'post_subject', $post_subjects); + $this->db->sql_transaction('commit'); + } + + /** + * Load existing topic/tag assignments for one migration batch. + * + * @param string $topic_tags_table Topic/tag map table + * @param array $topic_ids Topic identifiers + * @return array Existing relationship lookup + */ + protected function get_existing_assignments(string $topic_tags_table, array $topic_ids): array + { + $sql = 'SELECT topic_id, prefix_id + FROM ' . $topic_tags_table . ' + WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids); + $result = $this->db->sql_query($sql); + $existing = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $existing[(int) $row['topic_id']][(int) $row['prefix_id']] = true; + } + $this->db->sql_freeresult($result); + + return $existing; + } + + /** + * Update distinct text values with one CASE query. + * + * @param string $table Table name + * @param string $id_column Integer primary key column + * @param string $value_column Text column + * @param array $changes New values keyed by identifier + * @return void + */ + protected function bulk_update_text(string $table, string $id_column, string $value_column, array $changes): void + { + if (!$changes) + { + return; + } + + $cases = []; + foreach ($changes as $id => $value) + { + $cases[] = 'WHEN ' . (int) $id . " THEN '" . $this->db->sql_escape($value) . "'"; + } + + $sql = 'UPDATE ' . $table . ' + SET ' . $value_column . ' = CASE ' . $id_column . ' + ' . implode("\n\t\t\t\t", $cases) . ' + ELSE ' . $value_column . ' + END + WHERE ' . $this->db->sql_in_set($id_column, array_keys($changes)); + $this->db->sql_query($sql); + } +} diff --git a/migrations/v200_schema.php b/migrations/v200_schema.php new file mode 100644 index 0000000..5bbf6e7 --- /dev/null +++ b/migrations/v200_schema.php @@ -0,0 +1,97 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\migrations; + +/** + * Add relational topic tag schema. + */ +class v200_schema extends \phpbb\db\migration\migration +{ + /** + * {@inheritdoc} + */ + public function effectively_installed() + { + return $this->db_tools->sql_table_exists($this->table_prefix . 'topic_prefixes_topics') + && $this->db_tools->sql_table_exists($this->table_prefix . 'topic_prefixes_forums') + && $this->db_tools->sql_column_exists($this->table_prefix . 'topic_prefixes', 'prefix_color') + && $this->db_tools->sql_column_exists($this->table_prefix . 'topic_prefixes', 'prefix_order'); + } + + /** + * {@inheritdoc} + */ + public static function depends_on() + { + return ['\phpbb\topicprefixes\migrations\install_module']; + } + + /** + * {@inheritdoc} + */ + public function update_schema() + { + return [ + 'add_columns' => [ + $this->table_prefix . 'topic_prefixes' => [ + 'prefix_color' => ['VCHAR:6', '4A76A8'], + 'prefix_order' => ['UINT', 0], + ], + ], + 'add_index' => [ + $this->table_prefix . 'topic_prefixes' => [ + 'prefix_state_order' => ['prefix_enabled', 'prefix_order'], + ], + ], + 'add_tables' => [ + $this->table_prefix . 'topic_prefixes_forums' => [ + 'COLUMNS' => [ + 'forum_id' => ['UINT', 0], + 'prefix_id' => ['UINT', 0], + ], + 'PRIMARY_KEY' => ['forum_id', 'prefix_id'], + 'KEYS' => [ + 'prefix_id' => ['INDEX', ['prefix_id']], + ], + ], + $this->table_prefix . 'topic_prefixes_topics' => [ + 'COLUMNS' => [ + 'topic_id' => ['UINT', 0], + 'prefix_id' => ['UINT', 0], + ], + 'PRIMARY_KEY' => ['topic_id', 'prefix_id'], + 'KEYS' => [ + 'prefix_topic' => ['INDEX', ['prefix_id', 'topic_id']], + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function revert_schema() + { + return [ + 'drop_keys' => [ + $this->table_prefix . 'topic_prefixes' => ['prefix_state_order'], + ], + 'drop_columns' => [ + $this->table_prefix . 'topic_prefixes' => ['prefix_color', 'prefix_order'], + ], + 'drop_tables' => [ + $this->table_prefix . 'topic_prefixes_forums', + $this->table_prefix . 'topic_prefixes_topics', + ], + ]; + } +} diff --git a/prefixes/manager.php b/prefixes/manager.php deleted file mode 100644 index cb2b337..0000000 --- a/prefixes/manager.php +++ /dev/null @@ -1,121 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\prefixes; - -class manager implements manager_interface -{ - /** - * @var nestedset_prefixes - */ - protected $nestedset; - - /** - * Constructor - * - * @param nestedset_prefixes $nestedset - */ - public function __construct(nestedset_prefixes $nestedset) - { - $this->nestedset = $nestedset; - } - - /** - * @inheritdoc - */ - public function get_prefix($id) - { - $prefix = $this->nestedset->get_subtree_data($id); - - return count($prefix) ? $prefix[$id] : false; - } - - /** - * @inheritdoc - */ - public function get_prefixes($forum_id = 0) - { - if ($forum_id) - { - $this->nestedset->where_forum_id($forum_id); - } - - return $this->nestedset->get_all_tree_data(); - } - - /** - * @inheritdoc - */ - public function get_active_prefixes($forum_id = 0) - { - return array_filter($this->get_prefixes($forum_id), [$this, 'is_enabled']); - } - - /** - * @inheritdoc - */ - public function add_prefix($tag, $forum_id) - { - $data = [ - 'prefix_tag' => $tag, - 'forum_id' => (int) $forum_id, - 'prefix_enabled' => true, - ]; - - return $tag !== '' ? $this->nestedset->insert($data) : false; - } - - /** - * @inheritdoc - */ - public function delete_prefix($id) - { - return $this->nestedset->delete($id); - } - - /** - * @inheritdoc - */ - public function update_prefix($id, array $data) - { - return $this->nestedset->update_item($id, $data); - } - - /** - * @inheritdoc - */ - public function move_prefix($id, $direction = 'up', $amount = 1) - { - $amount = (int) $amount; - - $this->nestedset->move($id, ($direction !== 'up' ? -$amount : $amount)); - } - - /** - * @inheritdoc - */ - public function is_enabled(array $row) - { - return $row['prefix_enabled']; - } - - /** - * @inheritdoc - */ - public function prepend_prefix($prefix, $subject) - { - if ($prefix && strpos($subject, $prefix) !== 0) - { - $subject = $prefix . ' ' . $subject; - } - - return $subject; - } -} diff --git a/prefixes/manager_interface.php b/prefixes/manager_interface.php deleted file mode 100644 index 0b3a465..0000000 --- a/prefixes/manager_interface.php +++ /dev/null @@ -1,94 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\prefixes; - -interface manager_interface -{ - /** - * Get a topic prefix by its identifier - * - * @param int $id Topic prefix identifier - * @return mixed Array of data, or false if no data found - */ - public function get_prefix($id); - - /** - * Get topic prefixes from the specified forum, otherwise get all - * - * @param int $forum_id Forum identifier - * @return array An array of topic prefix data - */ - public function get_prefixes($forum_id = 0); - - /** - * Get enabled topic prefixes from the specified forum, otherwise get all - * - * @param int $forum_id Forum identifier - * @return array An array of topic prefix data - */ - public function get_active_prefixes($forum_id = 0); - - /** - * Add a topic prefix to the database - * - * @param string $tag Topic prefix tag/name - * @param int $forum_id Forum identifier - * @return mixed Array with new prefix data as set in the database, false on error - */ - public function add_prefix($tag, $forum_id); - - /** - * Delete a topic prefix from the database - * - * @param int $id Topic prefix identifier - * @return array Item ids that have been deleted - * @throws \OutOfBoundsException - */ - public function delete_prefix($id); - - /** - * Update a topic prefix in the database - * - * @param int $id The item identifier - * @param array $data SQL array of data to update - * @return mixed Number of the affected rows updated, or false - * @throws \OutOfBoundsException - */ - public function update_prefix($id, array $data); - - /** - * Move a prefix up/down - * - * @param int $id The prefix identifier to move - * @param string $direction The direction (up|down) - * @param int $amount The number of places to move the rule - * @return void - * @throws \OutOfBoundsException - */ - public function move_prefix($id, $direction = 'up', $amount = 1); - - /** - * Check if a topic prefix is enabled - * - * @param array $row A row of topic prefix data - * @return bool True if enabled, or false - */ - public function is_enabled(array $row); - - /** - * Prepend a topic prefix to a topic title/subject - * - * @param string $prefix A topic prefix - * @param string $subject A topic title/subject - * @return string Updated topic title/subject - */ - public function prepend_prefix($prefix, $subject); -} diff --git a/prefixes/nestedset_prefixes.php b/prefixes/nestedset_prefixes.php deleted file mode 100644 index 7c92d54..0000000 --- a/prefixes/nestedset_prefixes.php +++ /dev/null @@ -1,79 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\prefixes; - -/** - * Nested set class for Topic Prefixes - */ -class nestedset_prefixes extends \phpbb\tree\nestedset -{ - /** - * Construct - * - * @param \phpbb\db\driver\driver_interface $db Database connection - * @param \phpbb\lock\db $lock Lock class used to lock the table when moving forums around - * @param string $table_name Table name - */ - public function __construct(\phpbb\db\driver\driver_interface $db, \phpbb\lock\db $lock, $table_name) - { - parent::__construct( - $db, - $lock, - $table_name, - 'TOPIC_PREFIXES_', - '', - [], - [ - 'item_id' => 'prefix_id', - 'parent_id' => 'prefix_parent_id', - 'left_id' => 'prefix_left_id', - 'right_id' => 'prefix_right_id', - 'item_parents' => 'prefix_parents', - ] - ); - } - - /** - * Set additional sql where restrictions to use the forum id - * - * @param int $forum_id The forum identifier - * @return nestedset_prefixes $this object for chaining calls - */ - public function where_forum_id($forum_id) - { - $this->sql_where = '%sforum_id = ' . (int) $forum_id; - - return $this; - } - - /** - * Update a nested item - * - * @param int $item_id The item identifier - * @param array $item_data SQL array of data to update - * @return mixed Number of the affected rows updated, or false - * @throws \OutOfBoundsException - */ - public function update_item($item_id, array $item_data) - { - if (!$item_id) - { - throw new \OutOfBoundsException($this->message_prefix . 'INVALID_ITEM'); - } - - $sql = 'UPDATE ' . $this->table_name . ' - SET ' . $this->db->sql_build_array('UPDATE', $item_data) . ' - WHERE ' . $this->column_item_id . ' = ' . (int) $item_id; - $this->db->sql_query($sql); - - return $this->db->sql_affectedrows(); - } -} 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..bd8306f --- /dev/null +++ b/styles/all/template/event/overall_header_head_append.html @@ -0,0 +1 @@ +{% INCLUDECSS '@phpbb_topicprefixes/topic_tags.css' %} diff --git a/styles/all/template/event/posting_editor_subject_before.html b/styles/all/template/event/posting_editor_subject_before.html index 9a4dc71..9dbe53b 100644 --- a/styles/all/template/event/posting_editor_subject_before.html +++ b/styles/all/template/event/posting_editor_subject_before.html @@ -1,14 +1,15 @@ -{% if PREFIXES and S_NEW_MESSAGE and not S_PRIVMSGS %} - {% INCLUDEJS '@phpbb_topicprefixes/js/topic_prefixes.js' %} -
-
+{% if S_TOPIC_TAGS and not S_PRIVMSGS %} +
+
{{ lang('TOPIC_TAGS') ~ lang('COLON') }}
- + {% endfor %} - + +
{% endif %} diff --git a/styles/all/template/event/search_results_topic_title_after.html b/styles/all/template/event/search_results_topic_title_after.html new file mode 100644 index 0000000..55cb38a --- /dev/null +++ b/styles/all/template/event/search_results_topic_title_after.html @@ -0,0 +1,7 @@ +{% if searchresults.TOPIC_TAGS %} +
+ {% for tag in searchresults.TOPIC_TAGS %} + {{ tag.TAG_NAME }} + {% endfor %} +
+{% endif %} diff --git a/styles/all/template/event/topiclist_row_prepend.html b/styles/all/template/event/topiclist_row_prepend.html new file mode 100644 index 0000000..aa53241 --- /dev/null +++ b/styles/all/template/event/topiclist_row_prepend.html @@ -0,0 +1,13 @@ +{% if topicrow.TOPIC_TAGS %} + + {% for tag in topicrow.TOPIC_TAGS %} + {{ tag.TAG_NAME }} + {% endfor %} + +{% elseif searchresults.TOPIC_TAGS %} + + {% for tag in searchresults.TOPIC_TAGS %} + {{ tag.TAG_NAME }} + {% endfor %} + +{% endif %} diff --git a/styles/all/template/event/viewforum_body_topic_row_before.html b/styles/all/template/event/viewforum_body_topic_row_before.html new file mode 100644 index 0000000..9f8f36c --- /dev/null +++ b/styles/all/template/event/viewforum_body_topic_row_before.html @@ -0,0 +1,13 @@ +{% if S_TOPIC_TAG_FILTERS %} +
+
+ {{ lang('TOPIC_TAGS_FILTER') ~ lang('COLON') }} + + {% for tag in TOPIC_TAG_FILTERS %} + {{ tag.TAG_NAME }} + {% endfor %} + + {% if S_TOPIC_TAG_FILTERED %}{{ lang('TOPIC_TAGS_CLEAR_FILTERS') }}{% endif %} +
+
+{% endif %} diff --git a/styles/all/template/event/viewtopic_topic_title_append.html b/styles/all/template/event/viewtopic_topic_title_append.html new file mode 100644 index 0000000..2315d64 --- /dev/null +++ b/styles/all/template/event/viewtopic_topic_title_append.html @@ -0,0 +1,7 @@ +{% if TOPIC_TAGS %} + + {% for tag in TOPIC_TAGS %} + {{ tag.TAG_NAME }} + {% endfor %} + +{% endif %} diff --git a/styles/all/template/js/topic_prefixes.js b/styles/all/template/js/topic_prefixes.js deleted file mode 100644 index 9c5de9e..0000000 --- a/styles/all/template/js/topic_prefixes.js +++ /dev/null @@ -1,25 +0,0 @@ -(function($) { // Avoid conflicts with other libraries - - "use strict"; - - $(function() { - - var getPrefix = function(el) { - return el.val() ? el.find(":selected").text() : ""; - }; - - var $prefixMenu = $("select[name=topic_prefix]"), - $topicTitle = $("input[name=subject]"), - prefix = getPrefix($prefixMenu); - - $prefixMenu.on("change", function() { - var title = $topicTitle.val(); - title = title.replace(prefix, "").trim(); - prefix = getPrefix($(this)); - title = title.replace(prefix, "").trim(); - $topicTitle.val(prefix ? prefix + " " + title : title).trigger('focus'); - }); - - }); - -})(jQuery); // Avoid conflicts with other libraries diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css new file mode 100644 index 0000000..b7fa37d --- /dev/null +++ b/styles/all/theme/topic_tags.css @@ -0,0 +1,79 @@ +.topic-tags { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.topic-tag { + border: 2px solid transparent; + border-radius: 3px; + box-sizing: border-box; + display: inline-block; + font-size: 0.85em; + font-weight: 600; + line-height: 1.35; + padding: 1px 6px; + text-decoration: none; +} + +a.topic-tag:hover, +a.topic-tag:focus { + filter: brightness(0.9); + text-decoration: none; +} + +.topic-tag-selected { + box-shadow: 0 0 0 2px currentColor; +} + +.topic-tag-choices { + border: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 0; + padding: 0; +} + +.topic-tag-checkbox { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.topic-tag-checkbox:checked + .topic-tag { + box-shadow: 0 0 0 2px currentColor; +} + +.topic-tag-checkbox:checked + .topic-tag::before { + content: "\2713\00a0"; +} + +.topic-tag-checkbox:focus + .topic-tag { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.topic-tag-filter-panel { + margin-bottom: 10px; +} + +.topic-tag-filter-panel strong, +.topic-tag-clear { + margin-right: 8px; +} + +.topic-row-tags { + margin-right: 5px; + vertical-align: 1px; +} + +.viewtopic-tags { + font-size: 0.7em; + margin-left: 8px; + vertical-align: middle; +} diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php new file mode 100644 index 0000000..2464111 --- /dev/null +++ b/tags/assignment_manager.php @@ -0,0 +1,121 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tags; + +use phpbb\db\driver\driver_interface; + +/** + * Topic/tag relationship manager. + */ +class assignment_manager +{ + /** @var driver_interface */ + protected $db; + + /** @var string Topic/tag map table */ + protected $topic_map_table; + + /** @var string Tag definition table */ + protected $tags_table; + + /** + * Constructor. + * + * @param driver_interface $db Database connection + * @param string $topic_map_table Topic/tag map table + * @param string $tags_table Tag definition table + */ + public function __construct(driver_interface $db, $topic_map_table, $tags_table) + { + $this->db = $db; + $this->topic_map_table = $topic_map_table; + $this->tags_table = $tags_table; + } + + /** + * Replace all tag assignments for one topic. + * + * Callers must validate tag availability before invoking this method. + * + * @param int $topic_id Topic identifier + * @param array $tag_ids Tag identifiers + * @return bool Whether topic identifier was valid + */ + public function set_topic_tags(int $topic_id, array $tag_ids): bool + { + $topic_id = (int) $topic_id; + $tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids)))); + if (!$topic_id) + { + return false; + } + + $this->db->sql_transaction('begin'); + $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' WHERE topic_id = ' . $topic_id); + $rows = []; + foreach ($tag_ids as $tag_id) + { + $rows[] = ['topic_id' => $topic_id, 'prefix_id' => $tag_id]; + } + if ($rows) + { + $this->db->sql_multi_insert($this->topic_map_table, $rows); + } + $this->db->sql_transaction('commit'); + + return true; + } + + /** + * Get assigned tag identifiers for one topic. + * + * @param int $topic_id Topic identifier + * @return array Tag identifiers + */ + public function get_topic_tag_ids(int $topic_id): array + { + $tags = $this->get_tags_for_topics([$topic_id]); + return isset($tags[$topic_id]) ? array_keys($tags[$topic_id]) : []; + } + + /** + * Batch-load tag definitions for topics. + * + * @param array $topic_ids Topic identifiers + * @return array Tags grouped by topic then tag identifier + */ + public function get_tags_for_topics(array $topic_ids): array + { + $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + if (!$topic_ids) + { + return []; + } + + $sql = 'SELECT pt.topic_id, p.* + FROM ' . $this->topic_map_table . ' pt + INNER JOIN ' . $this->tags_table . ' p + ON p.prefix_id = pt.prefix_id + WHERE ' . $this->db->sql_in_set('pt.topic_id', $topic_ids) . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tags = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $topic_id = (int) $row['topic_id']; + $tag_id = (int) $row['prefix_id']; + $tags[$topic_id][$tag_id] = $row; + } + $this->db->sql_freeresult($result); + + return $tags; + } +} diff --git a/tags/filter.php b/tags/filter.php new file mode 100644 index 0000000..d2e45c0 --- /dev/null +++ b/tags/filter.php @@ -0,0 +1,102 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tags; + +use phpbb\content_visibility; +use phpbb\db\driver\driver_interface; + +/** + * Viewforum topic tag query filter. + */ +class filter +{ + /** @var driver_interface */ + protected $db; + /** @var content_visibility */ + protected $visibility; + + /** @var string Topic/tag map table */ + protected $topic_map_table; + + /** @var string Topics table */ + protected $topics_table; + + /** + * Constructor. + * + * @param driver_interface $db Database connection + * @param content_visibility $visibility Content visibility service + * @param string $topic_map_table Topic/tag map table + * @param string $topics_table Topics table + */ + public function __construct(driver_interface $db, content_visibility $visibility, $topic_map_table, $topics_table) + { + $this->db = $db; + $this->visibility = $visibility; + $this->topic_map_table = $topic_map_table; + $this->topics_table = $topics_table; + } + + /** + * Build SQL condition requiring every selected tag. + * + * @param string $topic_alias Topics table alias + * @param array $tag_ids Selected tag identifiers + * @return string SQL condition + */ + public function condition(string $topic_alias, array $tag_ids): string + { + $tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids)))); + if (!$tag_ids) + { + return '1=1'; + } + + return $topic_alias . '.topic_id IN ( + SELECT tpf.topic_id + FROM ' . $this->topic_map_table . ' tpf + WHERE ' . $this->db->sql_in_set('tpf.prefix_id', $tag_ids) . ' + GROUP BY tpf.topic_id + HAVING COUNT(tpf.prefix_id) = ' . count($tag_ids) . ' + )'; + } + + /** + * Count visible forum topics matching selected tags. + * + * @param int $forum_id Forum identifier + * @param array $tag_ids Selected tag identifiers + * @param int $sort_days Age filter in days + * @return int Matching topic count + */ + public function count_topics(int $forum_id, array $tag_ids, int $sort_days = 0): int + { + $where = 't.forum_id = ' . (int) $forum_id . ' + AND ' . $this->condition('t', $tag_ids) . ' + AND ' . $this->visibility->get_visibility_sql('topic', (int) $forum_id, 't.'); + if ($sort_days) + { + $min_time = time() - ((int) $sort_days * 86400); + $where .= ' AND (t.topic_last_post_time >= ' . $min_time . ' + OR t.topic_type = ' . POST_ANNOUNCE . ' + OR t.topic_type = ' . POST_GLOBAL . ')'; + } + + $sql = 'SELECT COUNT(t.topic_id) AS num_topics + FROM ' . $this->topics_table . ' t + WHERE ' . $where; + $result = $this->db->sql_query($sql); + $count = (int) $this->db->sql_fetchfield('num_topics'); + $this->db->sql_freeresult($result); + + return $count; + } +} diff --git a/tags/manager.php b/tags/manager.php new file mode 100644 index 0000000..e35ae9a --- /dev/null +++ b/tags/manager.php @@ -0,0 +1,436 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tags; + +use phpbb\db\driver\driver_interface; + +/** + * Topic tag definition and forum availability manager. + */ +class manager +{ + const DEFAULT_COLOR = '4A76A8'; + + /** @var driver_interface */ + protected $db; + + /** @var string Tag definition table */ + protected $tags_table; + + /** @var string Forum/tag map table */ + protected $forums_map_table; + + /** @var string Topic/tag map table */ + protected $topic_map_table; + + /** @var string Forums table */ + protected $forums_table; + + /** + * Constructor. + * + * @param driver_interface $db Database connection + * @param string $tags_table Tag definition table + * @param string $forums_map_table Forum/tag map table + * @param string $topic_map_table Topic/tag map table + * @param string $forums_table Forums table + */ + public function __construct(driver_interface $db, $tags_table, $forums_map_table, $topic_map_table, $forums_table) + { + $this->db = $db; + $this->tags_table = $tags_table; + $this->forums_map_table = $forums_map_table; + $this->topic_map_table = $topic_map_table; + $this->forums_table = $forums_table; + } + + /** + * Get one tag and its available forums. + * + * @param int $tag_id Tag identifier + * @return array|false Tag data, or false when missing + */ + public function get_tag(int $tag_id) + { + $sql = 'SELECT * + FROM ' . $this->tags_table . ' + WHERE prefix_id = ' . (int) $tag_id; + $result = $this->db->sql_query($sql); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + if ($row) + { + $row['forum_ids'] = $this->get_forum_ids($row['prefix_id']); + } + + return $row; + } + + /** + * Get every tag in display order. + * + * @return array Tags keyed by identifier + */ + public function get_tags(): array + { + $sql = 'SELECT * + FROM ' . $this->tags_table . ' + ORDER BY prefix_order ASC, prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tags = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $tags[(int) $row['prefix_id']] = $row; + } + $this->db->sql_freeresult($result); + + return $tags; + } + + /** + * Get tags available in one forum. + * + * @param int $forum_id Forum identifier + * @param bool $enabled_only Return enabled tags only + * @return array Tags keyed by identifier + */ + public function get_available_tags(int $forum_id, bool $enabled_only = true): array + { + $sql = 'SELECT p.* + FROM ' . $this->tags_table . ' p + INNER JOIN ' . $this->forums_map_table . ' pf + ON pf.prefix_id = p.prefix_id + WHERE pf.forum_id = ' . (int) $forum_id . + ($enabled_only ? ' AND p.prefix_enabled = 1' : '') . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tags = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $tags[(int) $row['prefix_id']] = $row; + } + $this->db->sql_freeresult($result); + + return $tags; + } + + /** + * Get submitted tags that are enabled and available in a forum. + * + * @param int $forum_id Forum identifier + * @param array $tag_ids Submitted tag identifiers + * @return array Valid tags keyed by identifier + */ + public function get_assignable_tags(int $forum_id, array $tag_ids): array + { + $tag_ids = array_values(array_unique(array_map('intval', $tag_ids))); + if (!$tag_ids) + { + return []; + } + + $sql = 'SELECT p.* + FROM ' . $this->tags_table . ' p + INNER JOIN ' . $this->forums_map_table . ' pf + ON pf.prefix_id = p.prefix_id + WHERE pf.forum_id = ' . (int) $forum_id . ' + AND p.prefix_enabled = 1 + AND ' . $this->db->sql_in_set('p.prefix_id', $tag_ids) . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tags = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $tags[(int) $row['prefix_id']] = $row; + } + $this->db->sql_freeresult($result); + + return $tags; + } + + /** + * Create tag definition and forum relationships. + * + * @param string $name Tag text + * @param string $color Six-digit hexadecimal background color + * @param bool $enabled Enabled state + * @param array $forum_ids Available forum identifiers + * @return array|false Created tag, or false for invalid data + */ + public function add_tag(string $name, string $color, bool $enabled, array $forum_ids) + { + $name = trim($name); + $color = $this->normalize_color($color); + if ($name === '' || $color === false) + { + return false; + } + + $sql = 'SELECT MAX(prefix_order) AS max_order FROM ' . $this->tags_table; + $result = $this->db->sql_query($sql); + $max_order = (int) $this->db->sql_fetchfield('max_order'); + $this->db->sql_freeresult($result); + + $this->db->sql_transaction('begin'); + $sql = 'INSERT INTO ' . $this->tags_table . ' ' . $this->db->sql_build_array('INSERT', [ + 'prefix_tag' => $name, + 'prefix_color' => $color, + 'prefix_enabled' => (int) (bool) $enabled, + 'prefix_order' => $max_order + 1, + ]); + $this->db->sql_query($sql); + $tag_id = (int) $this->db->sql_nextid(); + $this->replace_forums($tag_id, $forum_ids); + $this->db->sql_transaction('commit'); + + return $this->get_tag($tag_id); + } + + /** + * Update tag definition and forum relationships. + * + * @param int $tag_id Tag identifier + * @param string $name Tag text + * @param string $color Six-digit hexadecimal background color + * @param bool $enabled Enabled state + * @param array $forum_ids Available forum identifiers + * @return array|false Updated tag, or false when invalid or missing + */ + public function update_tag(int $tag_id, string $name, string $color, bool $enabled, array $forum_ids) + { + $tag_id = (int) $tag_id; + $name = trim($name); + $color = $this->normalize_color($color); + if (!$tag_id || $name === '' || $color === false || !$this->tag_exists($tag_id)) + { + return false; + } + + $this->db->sql_transaction('begin'); + $sql = 'UPDATE ' . $this->tags_table . ' + SET ' . $this->db->sql_build_array('UPDATE', [ + 'prefix_tag' => $name, + 'prefix_color' => $color, + 'prefix_enabled' => (int) (bool) $enabled, + ]) . ' + WHERE prefix_id = ' . $tag_id; + $this->db->sql_query($sql); + $this->replace_forums($tag_id, $forum_ids); + $this->db->sql_transaction('commit'); + + return $this->get_tag($tag_id); + } + + /** + * Set tag enabled state. + * + * @param int $tag_id Tag identifier + * @param bool $enabled Enabled state + * @return bool Whether tag existed + */ + public function set_enabled(int $tag_id, bool $enabled): bool + { + if (!$this->tag_exists($tag_id)) + { + return false; + } + + $sql = 'UPDATE ' . $this->tags_table . ' + SET prefix_enabled = ' . (int) (bool) $enabled . ' + WHERE prefix_id = ' . (int) $tag_id; + $this->db->sql_query($sql); + + return true; + } + + /** + * Delete tag and all relationships. + * + * @param int $tag_id Tag identifier + * @return bool Whether tag existed + */ + public function delete_tag(int $tag_id): bool + { + $tag_id = (int) $tag_id; + if (!$this->tag_exists($tag_id)) + { + return false; + } + + $this->db->sql_transaction('begin'); + $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' WHERE prefix_id = ' . $tag_id); + $this->db->sql_query('DELETE FROM ' . $this->forums_map_table . ' WHERE prefix_id = ' . $tag_id); + $this->db->sql_query('DELETE FROM ' . $this->tags_table . ' WHERE prefix_id = ' . $tag_id); + $this->db->sql_transaction('commit'); + + return true; + } + + /** + * Move tag one position in display order. + * + * @param int $tag_id Tag identifier + * @param string $direction Move direction: up or down + * @return bool Whether tag existed + */ + public function move_tag(int $tag_id, string $direction): bool + { + $tags = array_values($this->get_tags()); + $current = false; + foreach ($tags as $index => $tag) + { + if ((int) $tag['prefix_id'] === (int) $tag_id) + { + $current = $index; + break; + } + } + + if ($current === false) + { + return false; + } + + $target = $direction === 'down' ? $current + 1 : $current - 1; + if (!isset($tags[$target])) + { + return true; + } + + $current_id = (int) $tags[$current]['prefix_id']; + $current_order = (int) $tags[$current]['prefix_order']; + $target_id = (int) $tags[$target]['prefix_id']; + $target_order = (int) $tags[$target]['prefix_order']; + + $this->db->sql_transaction('begin'); + $sql = 'UPDATE ' . $this->tags_table . ' + SET prefix_order = CASE prefix_id + WHEN ' . $current_id . ' THEN ' . $target_order . ' + WHEN ' . $target_id . ' THEN ' . $current_order . ' + END + WHERE ' . $this->db->sql_in_set('prefix_id', [$current_id, $target_id]); + $this->db->sql_query($sql); + $this->db->sql_transaction('commit'); + + return true; + } + + /** + * Get forum identifiers assigned to one tag. + * + * @param int $tag_id Tag identifier + * @return array Forum identifiers + */ + public function get_forum_ids(int $tag_id): array + { + $sql = 'SELECT forum_id + FROM ' . $this->forums_map_table . ' + WHERE prefix_id = ' . (int) $tag_id . ' + ORDER BY forum_id ASC'; + $result = $this->db->sql_query($sql); + $forum_ids = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $forum_ids[] = (int) $row['forum_id']; + } + $this->db->sql_freeresult($result); + + return $forum_ids; + } + + /** + * Get forum names grouped by tag. + * + * @return array Forum names keyed by tag identifier + */ + public function get_forum_names_by_tag(): array + { + $sql = 'SELECT pf.prefix_id, f.forum_name + FROM ' . $this->forums_map_table . ' pf + INNER JOIN ' . $this->forums_table . ' f + ON f.forum_id = pf.forum_id + ORDER BY f.left_id ASC'; + $result = $this->db->sql_query($sql); + $forums = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $forums[(int) $row['prefix_id']][] = $row['forum_name']; + } + $this->db->sql_freeresult($result); + + return $forums; + } + + /** + * Normalize and validate hexadecimal color. + * + * @param string $color Submitted color + * @return string|false Normalized color, or false when invalid + */ + public function normalize_color(string $color) + { + $color = strtoupper(ltrim(trim($color), '#')); + return preg_match('/^[0-9A-F]{6}$/D', $color) ? $color : false; + } + + /** + * Check whether tag exists. + * + * @param int $tag_id Tag identifier + * @return bool + */ + protected function tag_exists(int $tag_id): bool + { + $sql = 'SELECT prefix_id FROM ' . $this->tags_table . ' WHERE prefix_id = ' . (int) $tag_id; + $result = $this->db->sql_query($sql); + $exists = $this->db->sql_fetchfield('prefix_id') !== false; + $this->db->sql_freeresult($result); + return $exists; + } + + /** + * Replace forum availability relationships for one tag. + * + * @param int $tag_id Tag identifier + * @param array $forum_ids Submitted forum identifiers + * @return void + */ + protected function replace_forums(int $tag_id, array $forum_ids): void + { + $forum_ids = array_values(array_unique(array_map('intval', $forum_ids))); + $valid_ids = []; + if ($forum_ids) + { + $sql = 'SELECT forum_id + FROM ' . $this->forums_table . ' + WHERE forum_type = ' . FORUM_POST . ' + AND ' . $this->db->sql_in_set('forum_id', $forum_ids); + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $valid_ids[] = (int) $row['forum_id']; + } + $this->db->sql_freeresult($result); + } + + $this->db->sql_query('DELETE FROM ' . $this->forums_map_table . ' WHERE prefix_id = ' . (int) $tag_id); + $rows = []; + foreach ($valid_ids as $forum_id) + { + $rows[] = ['forum_id' => $forum_id, 'prefix_id' => $tag_id]; + } + if ($rows) + { + $this->db->sql_multi_insert($this->forums_map_table, $rows); + } + } +} diff --git a/tags/renderer.php b/tags/renderer.php new file mode 100644 index 0000000..3083aaa --- /dev/null +++ b/tags/renderer.php @@ -0,0 +1,129 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tags; + +/** + * Topic tag presentation helper. + */ +class renderer +{ + /** @var string phpBB root path */ + protected $root_path; + + /** @var string PHP extension */ + protected $php_ext; + + /** + * Constructor. + * + * @param string $root_path phpBB root path + * @param string $php_ext PHP extension + */ + public function __construct($root_path, $php_ext) + { + $this->root_path = $root_path; + $this->php_ext = $php_ext; + } + + /** + * Convert tag records into safe template data. + * + * @param array $tags Tag records + * @param int $forum_id Forum identifier for filter URLs + * @param array $selected_ids Selected filter identifiers + * @param array $url_params Sorting parameters to preserve + * @param bool $toggle Toggle selected IDs in generated URLs + * @return array Template tag rows + */ + public function render(array $tags, int $forum_id = 0, array $selected_ids = [], array $url_params = [], bool $toggle = false): array + { + $selected_ids = array_values(array_unique(array_map('intval', $selected_ids))); + $rendered = []; + foreach ($tags as $tag) + { + $tag_id = (int) $tag['prefix_id']; + $is_selected = in_array($tag_id, $selected_ids, true); + $link_ids = $selected_ids; + if ($toggle && $is_selected) + { + $link_ids = array_values(array_diff($link_ids, [$tag_id])); + } + else if (!in_array($tag_id, $link_ids, true)) + { + $link_ids[] = $tag_id; + } + + $rendered[] = [ + 'TAG_ID' => $tag_id, + 'TAG_NAME' => $tag['prefix_tag'], + 'TAG_COLOR' => '#' . $tag['prefix_color'], + 'TAG_TEXT_COLOR' => $this->contrast_color($tag['prefix_color']), + 'S_SELECTED' => $is_selected, + 'U_FILTER' => $forum_id ? $this->filter_url($forum_id, $link_ids, $url_params) : '', + ]; + } + + return $rendered; + } + + /** + * Select black or white text with greatest WCAG contrast. + * + * @param string $hex Six-digit hexadecimal background color + * @return string Hexadecimal foreground color + */ + public function contrast_color(string $hex): string + { + $hex = ltrim($hex, '#'); + $channels = [ + hexdec(substr($hex, 0, 2)) / 255, + hexdec(substr($hex, 2, 2)) / 255, + hexdec(substr($hex, 4, 2)) / 255, + ]; + foreach ($channels as $key => $channel) + { + $channels[$key] = $channel <= 0.03928 ? $channel / 12.92 : pow(($channel + 0.055) / 1.055, 2.4); + } + $luminance = 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; + $white_contrast = 1.05 / ($luminance + 0.05); + $black_contrast = ($luminance + 0.05) / 0.05; + + return $white_contrast >= $black_contrast ? '#FFFFFF' : '#000000'; + } + + /** + * Build viewforum URL for selected tags. + * + * @param int $forum_id Forum identifier + * @param array $tag_ids Selected tag identifiers + * @param array $url_params Sorting parameters to preserve + * @return string Viewforum URL + */ + public function filter_url(int $forum_id, array $tag_ids, array $url_params = []): string + { + $params = ['f' => $forum_id]; + foreach (['st', 'sk', 'sd'] as $name) + { + if (isset($url_params[$name]) && $url_params[$name] !== '') + { + $params[$name] = $url_params[$name]; + } + } + $tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids)))); + if ($tag_ids) + { + sort($tag_ids, SORT_NUMERIC); + $params['tags'] = implode(',', $tag_ids); + } + + return append_sid($this->root_path . 'viewforum.' . $this->php_ext, http_build_query($params, '', '&')); + } +} diff --git a/tests/acp/acp_module_test.php b/tests/acp/acp_module_test.php index 7121651..1aaa7e5 100644 --- a/tests/acp/acp_module_test.php +++ b/tests/acp/acp_module_test.php @@ -3,11 +3,13 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016, 2022 phpBB Limited + * @copyright (c) 2016 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ +namespace phpbb\topicprefixes\tests\acp; + require_once __DIR__ . '/../../../../../includes/functions_module.php'; class acp_module_test extends \phpbb_test_case @@ -18,6 +20,9 @@ class acp_module_test extends \phpbb_test_case /** @var \phpbb\module\module_manager */ protected $module_manager; + /** + * Set up module manager dependencies. + */ protected function setUp(): void { global $phpbb_dispatcher, $phpbb_extension_manager, $phpbb_root_path, $phpEx; @@ -45,6 +50,9 @@ protected function setUp(): void $phpbb_dispatcher = new \phpbb_mock_event_dispatcher(); } + /** + * Test ACP module metadata. + */ public function test_module_info() { self::assertEquals([ @@ -62,6 +70,11 @@ public function test_module_info() ], $this->module_manager->get_module_infos('acp', 'acp_topic_prefixes_module')); } + /** + * Provide module authorization cases. + * + * @return array Test cases + */ public function module_auth_test_data() { return [ @@ -76,9 +89,12 @@ public function module_auth_test_data() */ public function test_module_auth($module_auth, $expected) { - self::assertEquals($expected, p_master::module_auth($module_auth, 0)); + self::assertEquals($expected, \p_master::module_auth($module_auth, 0)); } + /** + * Test module controller dispatch. + */ public function test_main_module() { global $phpbb_container, $request, $template; @@ -123,7 +139,7 @@ public function test_main_module() ->expects(self::once()) ->method('main'); - $p_master = new p_master(); + $p_master = new \p_master(); $p_master->module_ary[0]['is_duplicate'] = 0; $p_master->module_ary[0]['url_extra'] = ''; $p_master->load('acp', '\phpbb\topicprefixes\acp\topic_prefixes_module', 'manage'); diff --git a/tests/controller/add_prefix_test.php b/tests/controller/add_prefix_test.php deleted file mode 100644 index d239e56..0000000 --- a/tests/controller/add_prefix_test.php +++ /dev/null @@ -1,91 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class add_prefix_test extends admin_controller_base -{ - /** - * Data for test_add_prefix - * - * @return array - */ - public function data_add_prefix() - { - return [ - ['', true, true], - ['topic_prefix1', true, true], - ['topic_prefix2', true, false], - ['topic_prefix3', false, false], - ]; - } - - /** - * Test add_prefix() - * - * @dataProvider data_add_prefix - * @param $prefix - * @param $submit - * @param $valid_form - */ - public function test_add_prefix($prefix, $submit, $valid_form) - { - if ($submit) - { - self::$valid_form = $valid_form; - - $this->request->expects(static::once()) - ->method('is_set_post') - ->willReturn($submit); - - if (!$valid_form) - { - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - } - else - { - $valid_prefix = $prefix !== ''; - $this->request->expects(static::once()) - ->method('variable') - ->willReturnMap([ - ['prefix_tag', '', true, \phpbb\request\request_interface::REQUEST, $prefix], - ]); - $this->manager->expects(static::once()) - ->method('add_prefix') - ->willReturn($valid_prefix ? ['prefix_tag' => $prefix] : false); - $this->log->expects($valid_prefix ? static::once() : static::never()) - ->method('add') - ->with('admin', static::anything(), static::anything(), 'ACP_LOG_PREFIX_ADDED', static::anything(), [$prefix, 'Test Forum']); - $this->db->expects($valid_prefix ? static::once() : static::never()) - ->method('sql_fetchrow') - ->willReturn(['forum_name' => 'Test Forum']); - } - } - else - { - $this->manager->expects(static::never()) - ->method('add_prefix'); - $this->log->expects(static::never()) - ->method('add'); - } - - $this->controller->add_prefix(); - } -} diff --git a/tests/controller/admin_controller_base.php b/tests/controller/admin_controller_base.php deleted file mode 100644 index 6c1ce7f..0000000 --- a/tests/controller/admin_controller_base.php +++ /dev/null @@ -1,144 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -/** - * We need to use the same namesapce as the controller we are - * testing so we can override global scope functions (see bottom). - */ -namespace phpbb\topicprefixes\controller; - -require_once __DIR__ . '/../../../../../includes/functions_acp.php'; - -class admin_controller_base extends \phpbb_test_case -{ - /** @var bool A return value for confirm_box() */ - public static $confirm = true; - - /** @var bool A return value for check_form_key() */ - public static $valid_form = false; - - /** @var \phpbb\topicprefixes\controller\admin_controller */ - protected $controller; - - /** @var \phpbb\topicprefixes\prefixes\manager|\PHPUnit\Framework\MockObject\MockObject */ - protected $manager; - - /** @var \phpbb\language\language */ - protected $language; - - /** @var \phpbb\log\log|\PHPUnit\Framework\MockObject\MockObject */ - protected $log; - - /** @var \phpbb\request\request|\PHPUnit\Framework\MockObject\MockObject */ - protected $request; - - /** @var \phpbb\template\template|\PHPUnit\Framework\MockObject\MockObject */ - protected $template; - - /** @var \phpbb\user|\PHPUnit\Framework\MockObject\MockObject */ - protected $user; - - /** @var \phpbb\db\driver\driver_interface|\PHPUnit\Framework\MockObject\MockObject */ - protected $db; - - /** - * @inheritdoc - */ - protected function setUp(): void - { - global $db, $user, $phpbb_root_path, $phpEx; - - parent::setUp(); - - $this->manager = $this->getMockBuilder('\phpbb\topicprefixes\prefixes\manager') - ->disableOriginalConstructor() - ->getMock(); - - $this->language = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); - - $this->log = $this->getMockBuilder('\phpbb\log\log') - ->disableOriginalConstructor() - ->getMock(); - - $this->request = $this->getMockBuilder('\phpbb\request\request') - ->disableOriginalConstructor() - ->getMock(); - - $this->template = $this->getMockBuilder('\phpbb\template\template') - ->disableOriginalConstructor() - ->getMock(); - - $this->user = $this->getMockBuilder('\phpbb\user') - ->setConstructorArgs(array( - $this->language, - '\phpbb\datetime' - )) - ->getMock(); - $this->user->data['user_form_salt'] = ''; - $this->user->data['user_id'] = ''; - $this->user->ip = ''; - $user = $this->user; - - $db = $this->db = $this->getMockBuilder('\phpbb\db\driver\driver_interface') - ->disableOriginalConstructor() - ->getMock(); - - $this->controller = new \phpbb\topicprefixes\controller\admin_controller( - $this->manager, - $this->language, - $this->log, - $this->request, - $this->template, - $this->user, - $phpbb_root_path, - $phpEx - ); - } -} - -/** - * Mock confirm_box() - * Note: use the same namespace as the admin_controller - * - * @return bool - */ -function confirm_box() -{ - return \phpbb\topicprefixes\controller\admin_controller_base::$confirm; -} - -/** - * Mock add_form_key() - * Note: use the same namespace as the admin_controller - */ -function add_form_key() -{ -} - -/** - * Mock check_form_key() - * Note: use the same namespace as the admin_controller - * - * @return bool - */ -function check_form_key() -{ - return \phpbb\topicprefixes\controller\admin_controller_base::$valid_form; -} - -/** - * Mock make_forum_select() - * - * @return string - */ -function make_forum_select() -{ - return '#select menu#'; -} diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php new file mode 100644 index 0000000..11cab38 --- /dev/null +++ b/tests/controller/admin_controller_test.php @@ -0,0 +1,104 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\controller; + +require_once __DIR__ . '/../../../../../includes/functions_acp.php'; + +class admin_controller_test extends \phpbb_test_case +{ + public static $valid_hash = true; + public static $confirmed = false; + + protected $manager; + protected $renderer; + protected $language; + protected $log; + protected $request; + protected $template; + protected $user; + protected $controller; + + protected function setUp(): void + { + parent::setUp(); + global $phpbb_root_path, $phpEx, $user; + $this->manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); + $this->renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); + $this->language = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); + $this->log = $this->getMockBuilder('\phpbb\log\log')->disableOriginalConstructor()->getMock(); + $this->request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); + $this->template = $this->getMockBuilder('\phpbb\template\template')->disableOriginalConstructor()->getMock(); + $this->user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock(); + $this->user->data = array('user_id' => 2, 'user_form_salt' => 'salt'); + $this->user->ip = '127.0.0.1'; + $user = $this->user; + $this->controller = new admin_controller( + $this->manager, $this->renderer, $this->language, $this->log, $this->request, $this->template, $this->user + ); + $this->controller->set_u_action('adm.php?mode=manage'); + } + + public function test_display_settings_lists_tags_and_edit_form() + { + $this->manager->method('get_forum_names_by_tag')->willReturn(array(1 => array('Forum Two'))); + $this->manager->method('get_tags')->willReturn(array(1 => array( + 'prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_enabled' => 1, + ))); + $this->renderer->method('contrast_color')->with('D4351C')->willReturn('#FFFFFF'); + $this->template->expects(self::once())->method('assign_block_vars')->with('tags', self::callback(function ($row) { + return $row['TAG_NAME'] === 'Bug' && $row['FORUM_NAMES'] === array('Forum Two') && $row['TAG_TEXT_COLOR'] === '#FFFFFF'; + })); + $this->template->expects(self::once())->method('assign_vars')->with(self::callback(function ($vars) { + return $vars['TAG_ID'] === 0 && $vars['S_FORUM_OPTIONS'] === '#forum options#'; + })); + $this->controller->display_settings(); + } + + public function test_toggle_updates_enabled_state() + { + $this->request->method('variable')->willReturn('valid'); + $this->request->method('is_ajax')->willReturn(false); + $this->manager->expects(self::once())->method('get_tag')->with(1)->willReturn(array('prefix_enabled' => 1)); + $this->manager->expects(self::once())->method('set_enabled')->with(1, false)->willReturn(true); + $this->controller->toggle_tag(1); + } + + public function test_move_updates_order() + { + $this->request->method('variable')->willReturn('valid'); + $this->request->method('is_ajax')->willReturn(false); + $this->manager->expects(self::once())->method('move_tag')->with(2, 'down')->willReturn(true); + $this->controller->move_tag(2, 'down'); + } + + public function test_delete_requires_confirmation() + { + self::$confirmed = false; + $this->manager->expects(self::once())->method('get_tag')->with(1)->willReturn(array('prefix_tag' => 'Bug')); + $this->manager->expects(self::never())->method('delete_tag'); + $this->controller->delete_tag(1); + } +} + +function make_forum_select() +{ + return '#forum options#'; +} + +function check_link_hash() +{ + return admin_controller_test::$valid_hash; +} + +function confirm_box($check) +{ + return $check ? admin_controller_test::$confirmed : false; +} diff --git a/tests/controller/delete_prefix_test.php b/tests/controller/delete_prefix_test.php deleted file mode 100644 index 0c9ed45..0000000 --- a/tests/controller/delete_prefix_test.php +++ /dev/null @@ -1,95 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class delete_prefix_test extends admin_controller_base -{ - /** - * Data for test_delete_prefix - * - * @return array - */ - public function data_delete_prefix() - { - return array( - array(1, false), // valid prefix, not confirmed - array(1, true), // valid prefix, confirmed - array(0, true), // invalid prefix, confirmed - ); - } - - /** - * Test delete_prefix() method - * - * @dataProvider data_delete_prefix - * @param $prefix_id - * @param $confirm - */ - public function test_delete_prefix($prefix_id, $confirm) - { - self::$confirm = $confirm; - - if (!$confirm) - { - $this->manager->expects(self::never()) - ->method('get_prefix'); - $this->manager->expects(self::never()) - ->method('delete_prefix'); - $this->log->expects(self::never()) - ->method('add'); - } - else if ($prefix_id === 0) - { - $this->manager->expects(self::once()) - ->method('get_prefix') - ->with($prefix_id) - ->willReturn(false); - $this->manager->expects(self::once()) - ->method('delete_prefix') - ->will(self::throwException(new \OutOfBoundsException())); - $this->log->expects(self::never()) - ->method('add'); - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - } - else - { - $this->manager->expects(self::once()) - ->method('get_prefix') - ->with($prefix_id) - ->willReturn(['prefix_id' => $prefix_id, 'prefix_tag' => 'topic_prefix']); - $this->manager->expects(self::once()) - ->method('delete_prefix'); - $this->log->expects(self::once()) - ->method('add') - ->with('admin', static::anything(), static::anything(), 'ACP_LOG_PREFIX_DELETED', static::anything(), ['topic_prefix', 'Test Forum']); - $this->db->expects(static::once()) - ->method('sql_fetchrow') - ->willReturn(['forum_name' => 'Test Forum']); - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_NOTICE : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - } - - $this->controller->delete_prefix($prefix_id); - } -} diff --git a/tests/controller/display_settings_test.php b/tests/controller/display_settings_test.php deleted file mode 100644 index ce2e09f..0000000 --- a/tests/controller/display_settings_test.php +++ /dev/null @@ -1,76 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class display_settings_test extends admin_controller_base -{ - /** - * Data for test_display_settings - * - * @return array - */ - public function data_display_settings() - { - return [ - [[], 1], - [[1 => [ - 'prefix_id' => 1, - 'prefix_tag' => 'foo', - 'prefix_enabled' => true, - ]], 2], - [[1 => [ - 'prefix_id' => 1, - 'prefix_tag' => 'foo', - 'prefix_enabled' => true, - ], 2 => [ - 'prefix_id' => 2, - 'prefix_tag' => 'bar', - 'prefix_enabled' => true, - ]], 3], - ]; - } - - /** - * Test display_settings() - * - * @dataProvider data_display_settings - * @param $prefixes - * @param $forum_id - */ - public function test_display_settings($prefixes, $forum_id) - { - $this->manager->expects(static::once()) - ->method('get_prefixes') - ->willReturn($prefixes); - - $this->template->expects(static::exactly(count($prefixes))) - ->method('assign_block_vars'); - - $this->template->expects(static::once()) - ->method('assign_vars') - ->with(array( - 'S_FORUM_OPTIONS' => '#select menu#', - 'FORUM_ID' => $forum_id, - 'U_ACTION' => 'foo', - )); - - $this->controller - ->set_u_action('foo') - ->set_forum_id($forum_id) - ->display_settings(); - } -} diff --git a/tests/controller/edit_prefix_test.php b/tests/controller/edit_prefix_test.php deleted file mode 100644 index 04c34e2..0000000 --- a/tests/controller/edit_prefix_test.php +++ /dev/null @@ -1,101 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class edit_prefix_test extends admin_controller_base -{ - /** - * Data for test_edit_prefix - * - * @return array - */ - public function data_edit_prefix() - { - return array( - array(1, true, false), // valid prefix, valid form/hash, not ajax - array(1, true, true), // valid prefix, valid form/hash, valid ajax - array(1, false, false), // valid prefix, invalid form/hash, not ajax - array(0, true, false), // invalid prefix, valid form/hash, not ajax - ); - } - - /** - * Test edit_prefix() method - * - * @dataProvider data_edit_prefix - * @param $prefix_id - * @param $valid_form - */ - public function test_edit_prefix($prefix_id, $valid_form, $is_ajax) - { - $this->request->expects(self::once()) - ->method('variable') - ->with(self::anything()) - ->willReturnMap(array( - array('hash', '', false, \phpbb\request\request_interface::REQUEST, generate_link_hash(($valid_form ? 'edit' : '') . $prefix_id)) - )); - - if (!$valid_form) - { - $this->manager->expects(self::never()) - ->method('get_prefix'); - $this->manager->expects(self::never()) - ->method('update_prefix'); - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - } - else if ($prefix_id === 0) - { - $this->manager->expects(self::once()) - ->method('get_prefix') - ->with($prefix_id) - ->willReturn(false); - $this->manager->expects(self::once()) - ->method('update_prefix') - ->will(self::throwException(new \OutOfBoundsException)); - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - } - else - { - $this->manager->expects(self::once()) - ->method('get_prefix') - ->with($prefix_id) - ->willReturn(['prefix_id' => $prefix_id, 'prefix_enabled' => true]); - $this->manager->expects(self::once()) - ->method('update_prefix'); - - $this->request->expects(self::atMost(1)) - ->method('is_ajax') - ->willReturn($is_ajax); - - if ($is_ajax) - { - // Handle trigger_error() output called from json_response - $this->setExpectedTriggerError(E_WARNING); - } - } - - $this->controller->edit_prefix($prefix_id); - } -} diff --git a/tests/controller/main_test.php b/tests/controller/main_test.php deleted file mode 100644 index 1747b17..0000000 --- a/tests/controller/main_test.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class main_test extends admin_controller_base -{ - protected function setUp(): void - { - global $phpbb_root_path, $phpEx; - - parent::setUp(); - - $this->controller = $this->getMockBuilder('\phpbb\topicprefixes\controller\admin_controller') - ->setMethods(array('add_prefix', 'edit_prefix', 'delete_prefix', 'move_prefix', 'display_settings')) - ->setConstructorArgs(array( - $this->manager, - $this->language, - $this->log, - $this->request, - $this->template, - $this->user, - $phpbb_root_path, - $phpEx, - )) - ->getMock(); - } - - /** - * Data for test_main - * - * @return array - */ - public function data_main() - { - return array( - array('add', 'add_prefix'), - array('edit', 'edit_prefix'), - array('delete', 'delete_prefix'), - array('move_up', 'move_prefix'), - array('move_down', 'move_prefix'), - ); - } - - /** - * Test main() - * - * @dataProvider data_main - * @param $action - * @param $expected - */ - public function test_main($action, $expected) - { - $this->request->expects(static::any()) - ->method('variable') - ->willReturnMap(array( - array('action', '', false, \phpbb\request\request_interface::REQUEST, $action), - )); - - $this->controller->expects(static::once()) - ->method($expected); - - $this->controller->main(); - } -} diff --git a/tests/controller/move_prefix_test.php b/tests/controller/move_prefix_test.php deleted file mode 100644 index 8ebd2a5..0000000 --- a/tests/controller/move_prefix_test.php +++ /dev/null @@ -1,100 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\controller; - -/** - * We need to manually include the base file because auto loading won't work since the base - * uses the same namespace as the controller being tested (to override global scope functions). - */ -require_once __DIR__ . '/admin_controller_base.php'; - -class move_prefix_test extends admin_controller_base -{ - /** - * Data for test_move_prefix - * - * @return array - */ - public function data_move_prefix() - { - return array( - // prefix id, direction, valid form/hash, is ajax - array(1, 'up', true, false), - array(1, 'down', true, false), - array(2, 'up', true, false), - array(2, 'down', true, false), - array(3, 'up', true, true), - array(3, 'down', true, true), - array(1, 'up', false, false), - array(0, 'up', true, false), - ); - } - - /** - * Test move_prefix() method - * - * @dataProvider data_move_prefix - * @param $prefix_id - * @param $direction - * @param $valid_form - */ - public function test_move_prefix($prefix_id, $direction, $valid_form, $is_ajax) - { - $this->request->expects(static::once()) - ->method('variable') - ->with(static::anything()) - ->willReturnMap(array( - array('hash', '', false, \phpbb\request\request_interface::REQUEST, generate_link_hash($direction . $prefix_id)) - )); - - if (!$valid_form) - { - $prefix_id = 0; - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - $this->manager->expects(static::never()) - ->method('move_prefix'); - } - else if ($prefix_id === 0) - { - // Throws E_WARNING in PHP 8.0+ and E_USER_WARNING in earlier versions - $exceptionName = PHP_VERSION_ID < 80000 ? \PHPUnit\Framework\Error\Error::class : \PHPUnit\Framework\Error\Warning::class; - $errno = PHP_VERSION_ID < 80000 ? E_USER_WARNING : E_WARNING; - $this->expectException($exceptionName); - $this->expectExceptionCode($errno); - $this->manager->expects(static::once()) - ->method('move_prefix') - ->with(static::equalTo(0), static::stringContains($direction)) - ->will(static::throwException(new \OutOfBoundsException)); - } - else - { - $this->request->expects(static::once()) - ->method('is_ajax') - ->willReturn($is_ajax); - - if ($is_ajax) - { - // Handle trigger_error() output called from json_response - $this->setExpectedTriggerError(E_WARNING); - } - - $this->manager->expects(static::once()) - ->method('move_prefix') - ->with(static::equalTo($prefix_id), static::stringContains($direction)); - } - - $this->controller->move_prefix($prefix_id, $direction); - } -} diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php new file mode 100644 index 0000000..5ad6b9b --- /dev/null +++ b/tests/dbal/assignment_manager_test.php @@ -0,0 +1,37 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\dbal; + +require_once __DIR__ . '/tags_base.php'; + +class assignment_manager_test extends tags_base +{ + public function test_assign_multiple_edit_remove_and_deduplicate() + { + $manager = $this->create_assignment_manager(); + self::assertTrue($manager->set_topic_tags(13, array(1, 2, 2))); + self::assertSame(array(1, 2), $manager->get_topic_tag_ids(13)); + + self::assertTrue($manager->set_topic_tags(13, array(2))); + self::assertSame(array(2), $manager->get_topic_tag_ids(13)); + + self::assertTrue($manager->set_topic_tags(13, array())); + self::assertSame(array(), $manager->get_topic_tag_ids(13)); + } + + public function test_batch_retrieval() + { + $tags = $this->create_assignment_manager()->get_tags_for_topics(array(10, 11, 13)); + self::assertSame(array(1, 2), array_keys($tags[10])); + self::assertSame(array(1), array_keys($tags[11])); + self::assertArrayNotHasKey(13, $tags); + } +} diff --git a/tests/dbal/filter_test.php b/tests/dbal/filter_test.php new file mode 100644 index 0000000..c71a092 --- /dev/null +++ b/tests/dbal/filter_test.php @@ -0,0 +1,48 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\dbal; + +require_once __DIR__ . '/tags_base.php'; + +class filter_test extends tags_base +{ + protected function create_filter() + { + $visibility = $this->getMockBuilder('\phpbb\content_visibility')->disableOriginalConstructor()->getMock(); + $visibility->method('get_visibility_sql')->willReturn('1=1'); + return new \phpbb\topicprefixes\tags\filter($this->db, $visibility, 'phpbb_topic_prefixes_topics', 'phpbb_topics'); + } + + public function test_single_tag_filter() + { + self::assertSame(array(10, 11), $this->query_ids(array(1))); + } + + public function test_multiple_tags_use_and_semantics_without_duplicates() + { + self::assertSame(array(10), $this->query_ids(array(1, 2))); + } + + public function test_filtered_count_matches_rows() + { + self::assertSame(2, $this->create_filter()->count_topics(2, array(1))); + self::assertSame(1, $this->create_filter()->count_topics(2, array(1, 2))); + } + + protected function query_ids(array $tag_ids) + { + $sql = 'SELECT t.topic_id FROM phpbb_topics t WHERE t.forum_id = 2 AND ' . $this->create_filter()->condition('t', $tag_ids) . ' ORDER BY t.topic_id'; + $result = $this->db->sql_query($sql); + $ids = array_map('intval', array_column($this->db->sql_fetchrowset($result), 'topic_id')); + $this->db->sql_freeresult($result); + return $ids; + } +} diff --git a/tests/dbal/fixtures/topic_prefixes.xml b/tests/dbal/fixtures/topic_prefixes.xml deleted file mode 100644 index 7196548..0000000 --- a/tests/dbal/fixtures/topic_prefixes.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - prefix_id - prefix_tag - prefix_enabled - prefix_parent_id - prefix_left_id - prefix_right_id - prefix_parents - forum_id - - 1 - [TAG 1] - 1 - 0 - 1 - 2 - - 2 - - - 2 - [TAG 2] - 1 - 0 - 3 - 4 - - 2 - - - 3 - [TAG 3] - 1 - 0 - 5 - 6 - - 3 - - - 4 - [TAG 4] - 0 - 0 - 7 - 8 - - 2 - - - 5 - [TAG 5] - 0 - 0 - 9 - 10 - - 3 - -
- - topic_id - topic_title - topic_prefix_id - - 1 - - 0 - -
-
diff --git a/tests/dbal/fixtures/topic_tags.xml b/tests/dbal/fixtures/topic_tags.xml new file mode 100644 index 0000000..7ee5205 --- /dev/null +++ b/tests/dbal/fixtures/topic_tags.xml @@ -0,0 +1,32 @@ + + + + forum_idforum_nameforum_typeleft_idright_idforum_parentsforum_descforum_rules + 2Forum Two112 + 3Forum Three134 +
+ + prefix_idprefix_tagprefix_colorprefix_enabledprefix_order + 1BugD4351C11 + 2PHP 8.41D70B812 + 3Hidden505A5F03 + 4OtherF4773814 +
+ + forum_idprefix_id + 212223 + 3134 +
+ + topic_idforum_idtopic_titletopic_visibilitytopic_typetopic_last_post_time + 102Both tags102000000000 + 112Bug only102000000000 + 122PHP only102000000000 + 132No tags102000000000 +
+ + topic_idprefix_id + 101102 + 111122 +
+
diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php new file mode 100644 index 0000000..e7593a2 --- /dev/null +++ b/tests/dbal/legacy_migration_test.php @@ -0,0 +1,110 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\dbal; + +require_once __DIR__ . '/tags_base.php'; + +class legacy_migration_test extends tags_base +{ + /** @var \phpbb\db\tools\tools_interface */ + protected $tools; + + protected function setUp(): void + { + parent::setUp(); + $factory = new \phpbb\db\tools\factory(); + $this->tools = $factory->get($this->db); + $this->tools->sql_column_add('phpbb_topics', 'topic_prefix_id', array('UINT', 0)); + $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_parent_id', array('UINT', 0)); + $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_left_id', array('UINT', 0)); + $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_right_id', array('UINT', 0)); + $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_parents', array('MTEXT_UNI', '')); + $this->tools->sql_column_add('phpbb_topic_prefixes', 'forum_id', array('UINT', 0)); + + $this->db->sql_query("UPDATE phpbb_topic_prefixes SET prefix_left_id = prefix_id * 2 - 1, prefix_right_id = prefix_id * 2, forum_id = 2"); + $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_forums WHERE prefix_id = 4'); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'Bug Both tags', topic_prefix_id = 1, topic_first_post_id = 100 WHERE topic_id = 10"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1, topic_first_post_id = 101 WHERE topic_id = 11"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2, topic_first_post_id = 102 WHERE topic_id = 12"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0, topic_first_post_id = 103 WHERE topic_id = 13"); + foreach (array( + array(100, 10, 'Bug Both tags'), + array(101, 11, 'Unrelated first post'), + array(102, 12, 'PHP 8.4 PHP only'), + array(103, 13, '[Random] No tags'), + array(104, 10, 'Bug reply subject'), + ) as $post) + { + $sql = 'INSERT INTO phpbb_posts ' . $this->db->sql_build_array('INSERT', array( + 'post_id' => $post[0], + 'topic_id' => $post[1], + 'forum_id' => 2, + 'post_subject' => $post[2], + 'post_text' => '', + )); + $this->db->sql_query($sql); + } + } + + protected function tearDown(): void + { + $this->tools->sql_column_remove('phpbb_topics', 'topic_prefix_id'); + foreach (array('prefix_parent_id', 'prefix_left_id', 'prefix_right_id', 'prefix_parents', 'forum_id') as $column) + { + $this->tools->sql_column_remove('phpbb_topic_prefixes', $column); + } + parent::tearDown(); + } + + public function test_legacy_definitions_titles_subjects_and_idempotency() + { + $migration = $this->create_migration(); + $migration->migrate_legacy_data(); + $migration->migrate_legacy_data(); + + self::assertSame('4A76A8', $this->field('SELECT prefix_color FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_color')); + self::assertSame(1, (int) $this->field('SELECT prefix_order FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_order')); + self::assertSame('Both tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 10', 'topic_title')); + self::assertSame('Both tags', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 100', 'post_subject')); + self::assertSame('PHP only', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 12', 'topic_title')); + self::assertSame('PHP only', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 102', 'post_subject')); + self::assertSame('[Other] untouched', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 11', 'topic_title')); + self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 101', 'post_subject')); + self::assertSame('[Random] No tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 13', 'topic_title')); + self::assertSame('Bug reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 104', 'post_subject')); + self::assertSame(1, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_forums WHERE forum_id = 2 AND prefix_id = 4', 'total')); + + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = 10 AND prefix_id = 1'); + self::assertSame(1, (int) $this->db->sql_fetchfield('total')); + $this->db->sql_freeresult($result); + } + + protected function create_migration() + { + global $phpbb_root_path, $phpEx; + return new \phpbb\topicprefixes\migrations\v200_data( + new \phpbb\config\config(array()), + $this->db, + $this->tools, + $phpbb_root_path, + $phpEx, + 'phpbb_' + ); + } + + protected function field($sql, $field) + { + $result = $this->db->sql_query($sql); + $value = $this->db->sql_fetchfield($field); + $this->db->sql_freeresult($result); + return $value; + } +} diff --git a/tests/dbal/manager_add_prefix_test.php b/tests/dbal/manager_add_prefix_test.php deleted file mode 100644 index 8b00430..0000000 --- a/tests/dbal/manager_add_prefix_test.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_add_prefix_test extends manager_base -{ - /** - * Data for test_add_prefix - * - * @return array - */ - public function data_add_prefix() - { - return array( - array('[TEST 1]', 2, 6), - array('[TEST 2]', 3, 6), - array('0', 2, 6), - array('', 2, false), - ); - } - - /** - * Test the add_prefix() method - * - * @dataProvider data_add_prefix - * @param $tag - * @param $forum_id - * @param $expected - */ - public function test_add_prefix($tag, $forum_id, $expected) - { - $result = $this->manager->add_prefix($tag, $forum_id); - - if ($expected !== false) - { - self::assertEquals($expected, $result['prefix_id']); - } - else - { - self::assertFalse($result); - } - } -} diff --git a/tests/dbal/manager_base.php b/tests/dbal/manager_base.php deleted file mode 100644 index f68605b..0000000 --- a/tests/dbal/manager_base.php +++ /dev/null @@ -1,61 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_base extends \phpbb_database_test_case -{ - /** - * @var \phpbb\db\driver\driver_interface - */ - protected $db; - - /** - * @var \phpbb\topicprefixes\prefixes\manager - */ - protected $manager; - - /** - * @inheritdoc - */ - protected static function setup_extensions() - { - return ['phpbb/topicprefixes']; - } - - /** - * @inheritdoc - */ - public function getDataSet() - { - return $this->createXMLDataSet(__DIR__ . '/fixtures/topic_prefixes.xml'); - } - - /** - * @inheritdoc - */ - protected function setUp(): void - { - parent::setUp(); - - $this->db = $this->new_dbal(); - - $config = new \phpbb\config\config(array('topicprefixes.table_lock.topic_prefixes_table' => 0)); - $lock = new \phpbb\lock\db('topicprefixes.table_lock.topic_prefixes_table', $config, $this->db); - - $this->manager = new \phpbb\topicprefixes\prefixes\manager( - new \phpbb\topicprefixes\prefixes\nestedset_prefixes( - $this->db, - $lock, - 'phpbb_topic_prefixes' - ) - ); - } -} diff --git a/tests/dbal/manager_delete_prefix_test.php b/tests/dbal/manager_delete_prefix_test.php deleted file mode 100644 index a8045f6..0000000 --- a/tests/dbal/manager_delete_prefix_test.php +++ /dev/null @@ -1,48 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_delete_prefix_test extends manager_base -{ - /** - * Test delete_prefix() method - */ - public function test_delete_prefix() - { - self::assertEquals(array(1), $this->manager->delete_prefix(1)); - } - - /** - * Data for test_delete_prefix_fails - * - * @return array - */ - public function data_delete_prefix_fails() - { - return array( - array(0), - array(10), - ); - } - - /** - * Test delete_prefix() method - * - * @dataProvider data_delete_prefix_fails - * @param $id - */ - public function test_delete_prefix_fails($id) - { - $this->expectException(\OutOfBoundsException::class); - $this->expectExceptionMessage('TOPIC_PREFIXES_INVALID_ITEM'); - $this->manager->delete_prefix($id); - } -} diff --git a/tests/dbal/manager_get_active_prefixes_test.php b/tests/dbal/manager_get_active_prefixes_test.php deleted file mode 100644 index 49b9db1..0000000 --- a/tests/dbal/manager_get_active_prefixes_test.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_get_active_prefixes_test extends manager_base -{ - /** - * Data for test_get_active_prefixes - * - * @return array - */ - public function data_get_active_prefixes() - { - return array( - array(null, array('[TAG 1]', '[TAG 2]', '[TAG 3]')), - array('', array('[TAG 1]', '[TAG 2]', '[TAG 3]')), - array(0, array('[TAG 1]', '[TAG 2]', '[TAG 3]')), - array(2, array('[TAG 1]', '[TAG 2]')), - array(3, array('[TAG 3]')), - array(9, array()), - ); - } - - /** - * Test the get_active_prefixes() method - * - * @dataProvider data_get_active_prefixes - * @param $forum_id - * @param $expected - */ - public function test_get_active_prefixes($forum_id, $expected) - { - $prefixes = $this->manager->get_active_prefixes($forum_id); - - self::assertEquals($expected, array_column($prefixes, 'prefix_tag')); - } -} diff --git a/tests/dbal/manager_get_prefix_test.php b/tests/dbal/manager_get_prefix_test.php deleted file mode 100644 index 6f0750c..0000000 --- a/tests/dbal/manager_get_prefix_test.php +++ /dev/null @@ -1,55 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_get_prefix_test extends manager_base -{ - /** - * Data for test_get_prefix - * - * @return array - */ - public function data_get_prefix() - { - return array( - array(0, false), - array(10, false), - array('', false), - array(null, false), - array(1, '[TAG 1]'), - array(2, '[TAG 2]'), - array(3, '[TAG 3]'), - array(4, '[TAG 4]'), - array(5, '[TAG 5]'), - ); - } - - /** - * Test the get_prefix() method - * - * @dataProvider data_get_prefix - * @param $prefix_id - * @param $expected - */ - public function test_get_prefix($prefix_id, $expected) - { - $prefix = $this->manager->get_prefix($prefix_id); - - if ($expected !== false) - { - self::assertEquals($expected, $prefix['prefix_tag'], 'Assert get_prefix() gets the expected prefix data'); - } - else - { - self::assertFalse($prefix); - } - } -} diff --git a/tests/dbal/manager_get_prefixes_test.php b/tests/dbal/manager_get_prefixes_test.php deleted file mode 100644 index cc0a5f8..0000000 --- a/tests/dbal/manager_get_prefixes_test.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_get_prefixes_test extends manager_base -{ - /** - * Data for test_get_prefixes - * - * @return array - */ - public function data_get_prefixes() - { - return array( - array(null, array('[TAG 1]', '[TAG 2]', '[TAG 3]', '[TAG 4]', '[TAG 5]')), - array('', array('[TAG 1]', '[TAG 2]', '[TAG 3]', '[TAG 4]', '[TAG 5]')), - array(0, array('[TAG 1]', '[TAG 2]', '[TAG 3]', '[TAG 4]', '[TAG 5]')), - array(2, array('[TAG 1]', '[TAG 2]', '[TAG 4]')), - array(3, array('[TAG 3]', '[TAG 5]')), - array(9, array()), - ); - } - - /** - * Test the get_prefixes() method - * - * @dataProvider data_get_prefixes - * @param $forum_id - * @param $expected - */ - public function test_get_prefixes($forum_id, $expected) - { - $prefixes = $this->manager->get_prefixes($forum_id); - - self::assertEquals($expected, array_column($prefixes, 'prefix_tag')); - } -} diff --git a/tests/dbal/manager_move_prefix_test.php b/tests/dbal/manager_move_prefix_test.php deleted file mode 100644 index d9cc9b0..0000000 --- a/tests/dbal/manager_move_prefix_test.php +++ /dev/null @@ -1,121 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_move_prefix_test extends manager_base -{ - /** - * Data for test_move_prefix - * - * @return array - */ - public function data_move_prefix() - { - return array( - array( - 1, - 'up', // Move item 1 up (not expected to move) - array( - array('prefix_id' => 1, 'prefix_left_id' => 1), - array('prefix_id' => 2, 'prefix_left_id' => 3), - array('prefix_id' => 3, 'prefix_left_id' => 5), - array('prefix_id' => 4, 'prefix_left_id' => 7), - array('prefix_id' => 5, 'prefix_left_id' => 9), - ), - ), - array( - 1, - 'down', // Move item 1 down - array( - array('prefix_id' => 2, 'prefix_left_id' => 1), - array('prefix_id' => 1, 'prefix_left_id' => 3), - array('prefix_id' => 3, 'prefix_left_id' => 5), - array('prefix_id' => 4, 'prefix_left_id' => 7), - array('prefix_id' => 5, 'prefix_left_id' => 9), - ), - ), - array( - 3, - 'up', // Move item 3 up - array( - array('prefix_id' => 1, 'prefix_left_id' => 1), - array('prefix_id' => 3, 'prefix_left_id' => 3), - array('prefix_id' => 2, 'prefix_left_id' => 5), - array('prefix_id' => 4, 'prefix_left_id' => 7), - array('prefix_id' => 5, 'prefix_left_id' => 9), - ), - ), - array( - 3, - 'down', // Move item 3 down - array( - array('prefix_id' => 1, 'prefix_left_id' => 1), - array('prefix_id' => 2, 'prefix_left_id' => 3), - array('prefix_id' => 4, 'prefix_left_id' => 5), - array('prefix_id' => 3, 'prefix_left_id' => 7), - array('prefix_id' => 5, 'prefix_left_id' => 9), - ), - ), - array( - 5, - 'up', // Move item 5 up - array( - array('prefix_id' => 1, 'prefix_left_id' => 1), - array('prefix_id' => 2, 'prefix_left_id' => 3), - array('prefix_id' => 3, 'prefix_left_id' => 5), - array('prefix_id' => 5, 'prefix_left_id' => 7), - array('prefix_id' => 4, 'prefix_left_id' => 9), - ), - ), - array( - 5, - 'down', // Move item 5 down (not expected to move) - array( - array('prefix_id' => 1, 'prefix_left_id' => 1), - array('prefix_id' => 2, 'prefix_left_id' => 3), - array('prefix_id' => 3, 'prefix_left_id' => 5), - array('prefix_id' => 4, 'prefix_left_id' => 7), - array('prefix_id' => 5, 'prefix_left_id' => 9), - ), - ), - ); - } - - /** - * Test move_prefix() method - * - * @dataProvider data_move_prefix - * @param $id - * @param $direction - * @param $expected - */ - public function test_move_prefix($id, $direction, $expected) - { - $this->manager->move_prefix($id, $direction); - - $result = $this->db->sql_query('SELECT prefix_id, prefix_left_id - FROM phpbb_topic_prefixes - ORDER BY prefix_left_id ASC'); - - self::assertEquals($expected, $this->db->sql_fetchrowset($result)); - $this->db->sql_freeresult($result); - } - - /** - * Test move_prefix() method - */ - public function test_move_prefix_fails() - { - $this->expectException(\OutOfBoundsException::class); - $this->expectExceptionMessage('TOPIC_PREFIXES_INVALID_ITEM'); - $this->manager->move_prefix(123, 'up'); - } -} diff --git a/tests/dbal/manager_prepend_prefix_test.php b/tests/dbal/manager_prepend_prefix_test.php deleted file mode 100644 index 27bbf21..0000000 --- a/tests/dbal/manager_prepend_prefix_test.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_prepend_prefix_test extends manager_base -{ - /** - * Data for test_prepend_prefix - * - * @return array - */ - public function data_prepend_prefix() - { - return array( - array('[FOO]', 'Test subject', '[FOO] Test subject'), - array('[FOO][BAR]', 'Test subject', '[FOO][BAR] Test subject'), - array('', 'Test subject', 'Test subject'), - array(0, 'Test subject', 'Test subject'), - array(null, 'Test subject', 'Test subject'), - array('', '', ''), - array('[FOO]', '', '[FOO] '), - ); - } - - /** - * Test the prepend_prefix() method - * - * @dataProvider data_prepend_prefix - * @param $prefix - * @param $subject - * @param $expected - */ - public function test_prepend_prefix($prefix, $subject, $expected) - { - self::assertEquals($expected, $this->manager->prepend_prefix($prefix, $subject)); - } -} diff --git a/tests/dbal/manager_update_prefix_test.php b/tests/dbal/manager_update_prefix_test.php deleted file mode 100644 index 8876e43..0000000 --- a/tests/dbal/manager_update_prefix_test.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -class manager_update_prefix_test extends manager_base -{ - /** - * Data for test_update_prefix - * - * @return array - */ - public function data_update_prefix() - { - return array( - array(1, array('prefix_enabled' => 0), 1), - array(2, array('prefix_enabled' => 1), 1), - array(9, array('prefix_enabled' => 0), 0), - ); - } - - /** - * Test the update_prefix() method - * - * @dataProvider data_update_prefix - * @param $id - * @param $data - * @param $expected - */ - public function test_update_prefix($id, $data, $expected) - { - self::assertEquals($expected, $this->manager->update_prefix($id, $data)); - } - - /** - * Test the update_prefix() method - */ - public function test_update_prefix_fails() - { - $this->expectException(\OutOfBoundsException::class); - $this->expectExceptionMessage('TOPIC_PREFIXES_INVALID_ITEM'); - $this->manager->update_prefix(0, array()); - } -} diff --git a/tests/dbal/simple_test.php b/tests/dbal/simple_test.php deleted file mode 100644 index 0cf2b14..0000000 --- a/tests/dbal/simple_test.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\dbal; - -/** - * Class simple_test - */ -class simple_test extends \phpbb_database_test_case -{ - /** @var \phpbb\db\driver\driver_interface */ - protected $db; - - /** - * @inheritdoc - */ - protected static function setup_extensions() - { - return ['phpbb/topicprefixes']; - } - - /** - * @inheritdoc - */ - public function getDataSet() - { - return $this->createXMLDataSet(__DIR__ . '/fixtures/topic_prefixes.xml'); - } - - /** - * Test that the table exists in the database - */ - public function test_table_exists() - { - $this->db = $this->new_dbal(); - $factory = new \phpbb\db\tools\factory(); - $db_tools = $factory->get($this->db); - - self::assertTrue($db_tools->sql_column_exists('phpbb_topics', 'topic_prefix_id')); - self::assertTrue($db_tools->sql_table_exists('phpbb_topic_prefixes')); - } -} diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php new file mode 100644 index 0000000..f3950ee --- /dev/null +++ b/tests/dbal/tag_manager_test.php @@ -0,0 +1,81 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\dbal; + +require_once __DIR__ . '/tags_base.php'; + +class tag_manager_test extends tags_base +{ + public function test_schema_is_normalized() + { + $tools = (new \phpbb\db\tools\factory())->get($this->db); + self::assertTrue($tools->sql_table_exists('phpbb_topic_prefixes_forums')); + self::assertTrue($tools->sql_table_exists('phpbb_topic_prefixes_topics')); + self::assertFalse($tools->sql_column_exists('phpbb_topics', 'topic_prefix_id')); + self::assertFalse($tools->sql_column_exists('phpbb_topic_prefixes', 'forum_id')); + } + + public function test_crud_and_forum_availability() + { + $manager = $this->create_tag_manager(); + $tag = $manager->add_tag('Security', '#AA00CC', true, array(2, 3, 999)); + self::assertSame('Security', $tag['prefix_tag']); + self::assertSame('AA00CC', $tag['prefix_color']); + self::assertSame(array(2, 3), $tag['forum_ids']); + + $tag = $manager->update_tag($tag['prefix_id'], 'Security fix', '00AA00', false, array(3)); + self::assertSame('Security fix', $tag['prefix_tag']); + self::assertSame(0, (int) $tag['prefix_enabled']); + self::assertSame(array(3), $tag['forum_ids']); + + self::assertTrue($manager->delete_tag($tag['prefix_id'])); + self::assertFalse($manager->get_tag($tag['prefix_id'])); + } + + public function test_delete_cascades_availability_and_topic_assignments() + { + $manager = $this->create_tag_manager(); + self::assertTrue($manager->delete_tag(1)); + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_forums WHERE prefix_id = 1'); + self::assertSame(0, (int) $this->db->sql_fetchfield('total')); + $this->db->sql_freeresult($result); + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE prefix_id = 1'); + self::assertSame(0, (int) $this->db->sql_fetchfield('total')); + $this->db->sql_freeresult($result); + } + + public function test_available_tags_exclude_disabled_and_wrong_forum() + { + $tags = $this->create_tag_manager()->get_available_tags(2); + self::assertSame(array(1, 2), array_keys($tags)); + self::assertSame(array(1, 4), array_keys($this->create_tag_manager()->get_available_tags(3))); + } + + public function test_assignable_tags_reject_disabled_and_unavailable() + { + $tags = $this->create_tag_manager()->get_assignable_tags(2, array(1, 3, 4, 999)); + self::assertSame(array(1), array_keys($tags)); + } + + public function test_move_changes_global_order() + { + $manager = $this->create_tag_manager(); + self::assertTrue($manager->move_tag(2, 'up')); + self::assertSame(array(2, 1, 3, 4), array_keys($manager->get_tags())); + } + + public function test_color_validation() + { + $manager = $this->create_tag_manager(); + self::assertSame('AABBCC', $manager->normalize_color('#aabbcc')); + self::assertFalse($manager->normalize_color('red')); + } +} diff --git a/tests/dbal/tags_base.php b/tests/dbal/tags_base.php new file mode 100644 index 0000000..2d23321 --- /dev/null +++ b/tests/dbal/tags_base.php @@ -0,0 +1,53 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\dbal; + +abstract class tags_base extends \phpbb_database_test_case +{ + /** @var \phpbb\db\driver\driver_interface */ + protected $db; + + protected static function setup_extensions() + { + return array('phpbb/topicprefixes'); + } + + public function getDataSet() + { + return $this->createXMLDataSet(__DIR__ . '/fixtures/topic_tags.xml'); + } + + protected function setUp(): void + { + parent::setUp(); + $this->db = $this->new_dbal(); + } + + protected function create_tag_manager() + { + return new \phpbb\topicprefixes\tags\manager( + $this->db, + 'phpbb_topic_prefixes', + 'phpbb_topic_prefixes_forums', + 'phpbb_topic_prefixes_topics', + 'phpbb_forums' + ); + } + + protected function create_assignment_manager() + { + return new \phpbb\topicprefixes\tags\assignment_manager( + $this->db, + 'phpbb_topic_prefixes_topics', + 'phpbb_topic_prefixes' + ); + } +} diff --git a/tests/event/listener_test.php b/tests/event/listener_test.php deleted file mode 100644 index 2bbea07..0000000 --- a/tests/event/listener_test.php +++ /dev/null @@ -1,485 +0,0 @@ - - * @license GNU General Public License, version 2 (GPL-2.0) - * - */ - -namespace phpbb\topicprefixes\tests\event; - -class listener_test extends \phpbb_test_case -{ - /** - * @var \phpbb\topicprefixes\event\listener - */ - protected $listener; - - /** - * @var \PHPUnit\Framework\MockObject\MockObject|\phpbb\topicprefixes\prefixes\manager - */ - protected $manager; - - /** - * @var \PHPUnit\Framework\MockObject\MockObject|\phpbb\request\request - */ - protected $request; - - /** - * @var \phpbb\language\language - */ - protected $language; - - /** - * @inheritdoc - */ - protected function setUp(): void - { - parent::setUp(); - - global $phpbb_root_path, $phpEx; - - $this->manager = $this->getMockBuilder('\phpbb\topicprefixes\prefixes\manager') - ->setMethods(array('get_active_prefixes', 'get_prefix')) - ->disableOriginalConstructor() - ->getMock(); - - $this->request = $this->getMockBuilder('\phpbb\request\request') - ->disableOriginalConstructor() - ->getMock(); - - $this->language = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); - } - - /** - * Get basic event data for testing - * - * @return array - */ - protected function get_event_data() - { - return array( - 'forum_id' => 2, - 'page_data' => array(), - 'data' => array(), - 'mode' => 'post', - 'post_data' => array( - 'post_id' => 0, - 'topic_first_post_id' => 0, - ), - ); - } - - /** - * Create our event listener - */ - protected function set_listener() - { - $this->listener = new \phpbb\topicprefixes\event\listener( - $this->manager, - $this->request, - $this->language - ); - } - - /** - * Test the event listener is constructed correctly - */ - public function test_construct() - { - $this->set_listener(); - self::assertInstanceOf('\Symfony\Component\EventDispatcher\EventSubscriberInterface', $this->listener); - } - - /** - * Test the event listener is subscribing events - */ - public function test_getSubscribedEvents() - { - self::assertEquals(array( - 'core.posting_modify_template_vars', - 'core.posting_modify_submit_post_before', - 'core.submit_post_modify_sql_data', - ), array_keys(\phpbb\topicprefixes\event\listener::getSubscribedEvents())); - } - - /** - * Data set for test_add_to_posting_form - * - * @return array - */ - public function data_add_to_posting_form() - { - $prefix_data = array( - 1 => array( - 'prefix_id' => 1, - 'prefix_tag' => '[foo]', - 'prefix_enabled' => 1, - ) - ); - - return array( - array( // test no changes when not in expected mode - $prefix_data, - array('mode' => 'reply'), - 0, - array(), - ), - array( // test no changes when editing a reply post - $prefix_data, - array( - 'mode' => 'edit', - 'post_data' => array( - 'post_id' => 10, - 'topic_first_post_id' => 5, - ), - ), - 0, - array(), - ), - array( // test adding prefixes form when posting new topic - $prefix_data, - array('mode' => 'post'), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test adding prefixes when editing first post - $prefix_data, - array('mode' => 'edit'), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test finding existing prefixes when editing first post - $prefix_data, - array( - 'mode' => 'edit', - 'post_data' => array( - 'post_id' => 15, - 'topic_first_post_id' => 15, - 'topic_prefix_id' => '1', - ), - ), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '[foo]', - ), - ), - array( // test finding prefixes in title when editing first post - $prefix_data, - array( - 'mode' => 'edit', - 'post_data' => array( - 'post_id' => 20, - 'topic_first_post_id' => 20, - 'topic_title' => '[foo] bar was here', - ), - ), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '[foo]', - ), - ), - array( // test not finding prefixes in title when editing first post - $prefix_data, - array( - 'mode' => 'edit', - 'post_data' => array( - 'post_id' => 30, - 'topic_first_post_id' => 30, - 'topic_title' => '[bar] bar was here', - ), - ), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test adding selected prefix when posting new topic - $prefix_data, - array('mode' => 'post'), - 1, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '[foo]', - ), - ), - array( // test adding selected prefix when previewing a new topic - $prefix_data, - array( - 'mode' => 'post', - 'preview' => true, - ), - 1, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '[foo]', - ), - ), - array( // test adding null selected prefix when posting new topic with bad data - $prefix_data, - array('mode' => 'post'), - 2, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test adding null selected prefix when previewing new topic with bad data - $prefix_data, - array( - 'mode' => 'post', - 'preview' => true, - ), - 2, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test post preview mode shows the expected prefix when no prefix is selected - $prefix_data, - array( - 'mode' => 'edit', - 'preview' => true, - ), - 0, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '', - ), - ), - array( // test post preview mode shows the expected prefix when a prefix is selected - $prefix_data, - array( - 'mode' => 'edit', - 'preview' => true, - ), - 1, - array( - 'PREFIXES' => $prefix_data, - 'SELECTED_PREFIX' => '[foo]', - ), - ), - ); - } - - /** - * Test add_to_posting_form() method - * - * @dataProvider data_add_to_posting_form - * @param array $prefixes - * @param array $parameters - * @param string $selected - * @param array $expected - * - */ - public function test_add_to_posting_form($prefixes, $parameters, $selected, $expected) - { - $this->set_listener(); - - $test_data = array_merge($this->get_event_data(), $parameters); - - $data = new \phpbb\event\data($test_data); - - $this->manager->expects(self::atMost(1)) - ->method('get_active_prefixes') - ->willReturn($prefixes); - - $this->request->expects(isset($expected['SELECTED_PREFIX']) ? self::once() : self::never()) - ->method('variable') - ->willReturn($selected); - - $this->listener->add_to_posting_form($data); - - self::assertSame($expected, $data['page_data']); - } - - /** - * Data for test_submit_prefix_data - * - * @return array - */ - public function data_submit_prefix_data() - { - return array( - array( // test adding prefix to a subject when posting - array( - 'prefix_id' => 1, - 'prefix_tag' => '[foo]' - ), - array( - 'post_data' => array( - 'post_subject' => 'test subject', - ), - ), - array( - 'data' => array('topic_prefix_id' => 1), - 'post_data' => array('post_subject' => '[foo] test subject'), - ), - ), - array( // test adding prefix to a subject when editing - array( - 'prefix_id' => 2, - 'prefix_tag' => '[bar]' - ), - array( - 'mode' => 'edit', - 'post_data' => array( - 'post_subject' => 'test subject', - ), - ), - array( - 'data' => array('topic_prefix_id' => 2), - 'post_data' => array('post_subject' => '[bar] test subject'), - ), - ), - array( // test not adding prefix to a subject that already has the prefix - array( - 'prefix_id' => 3, - 'prefix_tag' => '[foobar]' - ), - array( - 'mode' => 'post', - 'post_data' => array( - 'post_subject' => '[foobar] test subject', - ), - ), - array( - 'data' => array('topic_prefix_id' => 3), - 'post_data' => array('post_subject' => '[foobar] test subject'), - ), - ), - array( // test no changes happen when no prefix was found - false, - array( - 'post_data' => array( - 'post_subject' => 'test subject', - ), - ), - array( - 'data' => array('topic_prefix_id' => 0), - 'post_data' => array('post_subject' => 'test subject'), - ), - ), - array( // test no changes happen when not posting or editing - false, - array( - 'mode' => 'reply', - 'post_data' => array( - 'post_subject' => 'test subject', - ), - ), - array( - 'data' => array('topic_prefix_id' => 0), - 'post_data' => array('post_subject' => 'test subject'), - ), - ), - ); - } - - /** - * Test the submit_prefix_data() method - * - * @dataProvider data_submit_prefix_data - * @param $prefix - * @param $parameters - * @param $expected - */ - public function test_submit_prefix_data($prefix, $parameters, $expected) - { - $this->set_listener(); - - $test_data = array_merge($this->get_event_data(), $parameters); - - $data = new \phpbb\event\data($test_data); - - $prefix_id = $prefix['prefix_id'] ?? 0; - - $this->request->expects(self::once()) - ->method('variable') - ->willReturnMap(array( - array('topic_prefix', 0, false, \phpbb\request\request_interface::REQUEST, $prefix_id), - )); - - $this->manager->expects(self::atMost(1)) - ->method('get_prefix') - ->willReturn($prefix); - - $this->listener->submit_prefix_data($data); - - self::assertSame($expected['data'], $data['data']); - self::assertSame($expected['post_data'], $data['post_data']); - } - - /** - * Data for test_save_prefix_to_topic - * - * @return array - */ - public function data_save_prefix_to_topic() - { - return array( - array( // test updating the sql when posting new topic - array( - 'post_mode' => 'post', - 'sql_data' => array(TOPICS_TABLE => array('sql' => array())), - 'data' => array('topic_prefix_id' => 1), - ), - array(TOPICS_TABLE => array('sql' => array('topic_prefix_id' => 1))), - ), - array( // test updating the sql when editing topic - array( - 'post_mode' => 'edit_topic', - 'sql_data' => array(TOPICS_TABLE => array('sql' => array())), - 'data' => array('topic_prefix_id' => 2), - ), - array(TOPICS_TABLE => array('sql' => array('topic_prefix_id' => 2))), - ), - array( // test updating the sql when editing the first topic - array( - 'post_mode' => 'edit_first_post', - 'sql_data' => array(TOPICS_TABLE => array('sql' => array())), - 'data' => array('topic_prefix_id' => 3), - ), - array(TOPICS_TABLE => array('sql' => array('topic_prefix_id' => 3))), - ), - array( // test not updating the sql when post mode is unexpected - array( - 'post_mode' => '', - 'sql_data' => array(TOPICS_TABLE => array('sql' => array())), - 'data' => array('topic_prefix_id' => 1), - ), - array(TOPICS_TABLE => array('sql' => array())), - ), - ); - } - - /** - * Test the save_prefix_to_topic() method - * - * @dataProvider data_save_prefix_to_topic - * @param $event_data - * @param $expected - */ - public function test_save_prefix_to_topic($event_data, $expected) - { - $this->set_listener(); - - $data = new \phpbb\event\data($event_data); - - $this->listener->save_prefix_to_topic($data); - - self::assertSame($expected, $data['sql_data']); - } -} diff --git a/tests/event/posting_listener_test.php b/tests/event/posting_listener_test.php new file mode 100644 index 0000000..75272d1 --- /dev/null +++ b/tests/event/posting_listener_test.php @@ -0,0 +1,124 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\event; + +class posting_listener_test extends \phpbb_test_case +{ + protected $manager; + protected $assignments; + protected $request; + protected $language; + + protected function setUp(): void + { + parent::setUp(); + global $phpbb_root_path, $phpEx; + $this->manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); + $this->assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager')->disableOriginalConstructor()->getMock(); + $this->request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); + $this->language = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); + } + + public function test_subscribed_events() + { + self::assertSame(array( + 'core.posting_modify_submission_errors', + 'core.posting_modify_template_vars', + 'core.posting_modify_submit_post_before', + 'core.submit_post_end', + ), array_keys(\phpbb\topicprefixes\event\posting_listener::getSubscribedEvents())); + } + + public function test_new_topic_form_supports_multiple_selected_tags() + { + $tags = $this->tags(); + $this->manager->expects(self::once())->method('get_available_tags')->with(2)->willReturn($tags); + $this->request->method('is_set_post')->willReturn(true); + $this->request->method('variable')->willReturn(array(1, 2)); + $event = new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'topic_id' => 0, 'post_data' => array(), 'page_data' => array(), + )); + + $this->listener()->add_to_posting_form($event); + self::assertTrue($event['page_data']['S_TOPIC_TAGS']); + self::assertCount(2, $event['page_data']['TOPIC_TAGS']); + self::assertTrue($event['page_data']['TOPIC_TAGS'][0]['S_SELECTED']); + self::assertTrue($event['page_data']['TOPIC_TAGS'][1]['S_SELECTED']); + } + + public function test_reply_form_is_untouched() + { + $this->manager->expects(self::never())->method('get_available_tags'); + $event = new \phpbb\event\data(array('mode' => 'reply', 'forum_id' => 2, 'topic_id' => 10, 'post_data' => array(), 'page_data' => array())); + $this->listener()->add_to_posting_form($event); + self::assertSame(array(), $event['page_data']); + } + + public function test_first_post_edit_loads_existing_assignments() + { + $this->manager->method('get_available_tags')->willReturn($this->tags()); + $this->request->method('is_set_post')->willReturn(false); + $this->assignments->expects(self::once())->method('get_topic_tag_ids')->with(10)->willReturn(array(2)); + $event = new \phpbb\event\data(array( + 'mode' => 'edit', 'forum_id' => 2, 'topic_id' => 10, + 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'page_data' => array(), + )); + $this->listener()->add_to_posting_form($event); + self::assertFalse($event['page_data']['TOPIC_TAGS'][0]['S_SELECTED']); + self::assertTrue($event['page_data']['TOPIC_TAGS'][1]['S_SELECTED']); + } + + public function test_invalid_disabled_or_unavailable_ids_are_rejected() + { + $this->request->method('is_set_post')->willReturn(true); + $this->request->method('variable')->willReturn(array(1, 3)); + $this->manager->expects(self::once())->method('get_assignable_tags')->with(2, array(1, 3))->willReturn(array(1 => $this->tags()[1])); + $event = new \phpbb\event\data(array( + 'submit' => true, 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), 'error' => array(), + )); + $this->listener()->validate_submission($event); + self::assertCount(1, $event['error']); + } + + public function test_valid_submission_saves_relationships_without_changing_subject() + { + $this->request->method('is_set_post')->willReturn(true); + $this->request->method('variable')->willReturn(array(1, 2, 2)); + $this->manager->method('get_assignable_tags')->willReturn($this->tags()); + $this->assignments->expects(self::once())->method('set_topic_tags')->with(42, array(1, 2)); + $listener = $this->listener(); + $before = new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'post_data' => array('post_subject' => 'Plain title'), + )); + $listener->capture_submission($before); + self::assertSame('Plain title', $before['post_data']['post_subject']); + $listener->save_assignments(new \phpbb\event\data(array('data' => array('topic_id' => 42)))); + } + + protected function listener() + { + return new \phpbb\topicprefixes\event\posting_listener( + $this->manager, + $this->assignments, + new \phpbb\topicprefixes\tags\renderer('./', 'php'), + $this->request, + $this->language + ); + } + + protected function tags() + { + return array( + 1 => array('prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C'), + 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8'), + ); + } +} diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php new file mode 100644 index 0000000..d144696 --- /dev/null +++ b/tests/event/viewforum_listener_test.php @@ -0,0 +1,51 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\event; + +class viewforum_listener_test extends \phpbb_test_case +{ + public function test_filter_modifies_ids_count_and_pagination() + { + $manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager')->disableOriginalConstructor()->getMock(); + $filter = $this->getMockBuilder('\phpbb\topicprefixes\tags\filter')->disableOriginalConstructor()->getMock(); + $request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->disableOriginalConstructor()->getMock(); + $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); + $tags = array( + 1 => array('prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_order' => 1), + 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8', 'prefix_order' => 2), + ); + $manager->method('get_available_tags')->willReturn($tags); + $request->method('variable')->with('tags', '')->willReturn('1,2'); + $filter->expects(self::once())->method('count_topics')->with(2, array(1, 2), 0)->willReturn(7); + $filter->method('condition')->willReturn('FILTER_CONDITION'); + $template->expects(self::once())->method('assign_vars'); + $renderer->method('render')->willReturn(array()); + $renderer->method('filter_url')->willReturn('./viewforum.php?f=2'); + $listener = new \phpbb\topicprefixes\event\viewforum_listener( + $manager, $assignments, $filter, $renderer, $request, $template, $language + ); + + $config = new \phpbb\event\data(array('forum_id' => 2, 'topics_count' => 20, 'sort_days' => 0, 'sort_key' => 't', 'sort_dir' => 'd')); + $listener->configure_filter($config); + self::assertSame(7, $config['topics_count']); + + $ids = new \phpbb\event\data(array('sql_ary' => array('WHERE' => 't.forum_id = 2'))); + $listener->filter_topic_ids($ids); + self::assertSame('t.forum_id = 2 AND FILTER_CONDITION', $ids['sql_ary']['WHERE']); + + $page = new \phpbb\event\data(array('base_url' => './viewforum.php?f=2', 'on_page' => 2, 'start_name' => 'start', 'per_page' => 25, 'generate_page_link_override' => false)); + $listener->preserve_pagination_filter($page); + self::assertSame('./viewforum.php?f=2&tags=1,2', $page['base_url']); + } +} diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index cfdf7a1..23c5211 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -12,9 +12,7 @@ use Symfony\Component\DomCrawler\Crawler; -/** - * @group functional - */ +/** @group functional */ class functional_test extends \phpbb_functional_test_case { const FORUM_ID = 2; @@ -27,162 +25,106 @@ protected static function setup_extensions() protected function setUp(): void { parent::setUp(); - - $this->add_lang_ext('phpbb/topicprefixes', [ - 'acp_topic_prefixes', - 'info_acp_topic_prefixes', - 'topic_prefixes' - ]); + $this->add_lang_ext('phpbb/topicprefixes', array('acp_topic_prefixes', 'info_acp_topic_prefixes', 'topic_prefixes')); } - public function test_acp_module_installation() + public function test_acp_module_and_create_edit_tag() { $this->login(); $this->admin_login(); + $crawler = $this->acp_page(); + $this->assertContainsLang('TOPIC_TAGS', $crawler->filter('#main')->text()); + self::assertCount(1, $crawler->filter('input[type="color"]')); + self::assertCount(1, $crawler->filter('select[name="forum_ids[]"][multiple]')); - $crawler = self::request('GET', "adm/index.php?i=\\phpbb\\topicprefixes\\acp\\topic_prefixes_module&mode=manage&sid={$this->sid}"); - - // Assert module appears in sidebar - $this->assertContainsLang('ACP_TOPIC_PREFIXES', $crawler->filter('.menu-block')->text()); - $this->assertContainsLang('ACP_MANAGE_PREFIXES', $crawler->filter('#activemenu')->text()); - - // Assert module content appears - $this->assertContainsLang('TOPIC_PREFIXES', $crawler->filter('#main h1')->text()); - $this->assertContainsLang('TOPIC_PREFIXES_EXPLAIN', $crawler->filter('#main')->text()); - - // Assert included template JS appears - $this->assertStringContainsString("./../ext/phpbb/topicprefixes/adm/style/acp_topic_prefixes.js", $crawler->filter('body')->html()); - - // Jump to the create page - $form = $crawler->selectButton($this->lang('GO'))->form(['forum_id' => self::FORUM_ID]); - $crawler = self::submit($form); - - // Assert we're on create page and prefixes are currently empty - $this->assertContainsLang('TOPIC_PREFIXES_EMPTY', $crawler->text()); - } - - public function test_acp_create_prefix() - { - $this->login(); - $this->admin_login(); - - self::assertEquals(1, $this->create_prefix('[foo1]', self::FORUM_ID)); - } - - public function test_acp_disable_prefix() - { - $this->login(); - $this->admin_login(); - - $prefix = $this->create_prefix('[foo2]', self::FORUM_ID); - $hash = $this->mock_link_hash('edit' . $prefix); - - // Disable the prefix - $crawler = self::request('GET', 'adm/index.php?i=\phpbb\topicprefixes\acp\topic_prefixes_module&mode=manage&action=edit&forum_id=' . self::FORUM_ID . "&prefix_id={$prefix}&hash={$hash}&sid={$this->sid}"); - self::assertCount(1, $crawler->filter('tr')->eq(2)->filter('.tp-toggle-off')); + $tag_id = $this->create_tag('Bug', '#d4351c', array(self::FORUM_ID)); + $crawler = $this->acp_page('action=edit&tag_id=' . $tag_id); + $form = $crawler->selectButton($this->lang('SUBMIT'))->form(array( + 'tag_name' => 'Confirmed bug', + 'tag_color' => '#aa00cc', + 'tag_enabled' => 1, + 'forum_ids' => array(self::FORUM_ID), + )); + self::submit($form); - // Enable the prefix - $crawler = self::request('GET', 'adm/index.php?i=\phpbb\topicprefixes\acp\topic_prefixes_module&mode=manage&action=edit&forum_id=' . self::FORUM_ID . "&prefix_id={$prefix}&hash={$hash}&sid={$this->sid}"); - self::assertCount(1, $crawler->filter('tr')->eq(2)->filter('.tp-toggle-on')); + $this->get_db(); + $result = $this->db->sql_query('SELECT prefix_tag, prefix_color FROM phpbb_topic_prefixes WHERE prefix_id = ' . $tag_id); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + self::assertSame('Confirmed bug', $row['prefix_tag']); + self::assertSame('AA00CC', $row['prefix_color']); } - public function test_acp_delete_prefix() + public function test_multiple_tags_posting_display_and_filtering() { $this->login(); $this->admin_login(); + $bug_id = $this->create_tag('Bug filter', '#d4351c', array(self::FORUM_ID)); + $php_id = $this->create_tag('PHP 8.4 filter', '#1d70b8', array(self::FORUM_ID)); - $prefix = $this->create_prefix('[foo3]', self::FORUM_ID); + $crawler = self::request('GET', 'posting.php?mode=post&f=' . self::FORUM_ID . "&sid={$this->sid}"); + self::assertGreaterThanOrEqual(2, $crawler->filter('input[name="topic_tags[]"]')->count()); - // Delete the prefix - $crawler = self::request('GET', 'adm/index.php?i=\phpbb\topicprefixes\acp\topic_prefixes_module&mode=manage&action=delete&forum_id=' . self::FORUM_ID . "&prefix_id={$prefix}&sid={$this->sid}"); + $topic = $this->create_topic(self::FORUM_ID, 'Structured tag title', 'Tagged first post', array( + 'topic_tags' => array($bug_id, $php_id), + 'topic_tags_present' => 1, + )); + $this->get_db(); + $result = $this->db->sql_query('SELECT topic_title, topic_first_post_id FROM phpbb_topics WHERE topic_id = ' . (int) $topic['topic_id']); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + self::assertSame('Structured tag title', $row['topic_title']); + $result = $this->db->sql_query('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . (int) $row['topic_first_post_id']); + self::assertSame('Structured tag title', $this->db->sql_fetchfield('post_subject')); + $this->db->sql_freeresult($result); + $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . (int) $topic['topic_id'] . ' ORDER BY prefix_id'); + self::assertSame(array($bug_id, $php_id), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); + $this->db->sql_freeresult($result); - // Confirm delete - $form = $crawler->selectButton('confirm')->form(); - $crawler = self::submit($form); + $crawler = self::request('GET', 'posting.php?mode=edit&f=' . self::FORUM_ID . '&p=' . (int) $row['topic_first_post_id'] . "&sid={$this->sid}"); + $form = $crawler->selectButton($this->lang('SUBMIT'))->form(); + $values = $form->getPhpValues(); + $values['topic_tags'] = array((string) $php_id); + $values['topic_tags_present'] = '1'; + self::$client->request('POST', $form->getUri(), $values); + self::assert_response_html(); + $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . (int) $topic['topic_id'] . ' ORDER BY prefix_id'); + self::assertSame(array($php_id), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); + $this->db->sql_freeresult($result); - // Assert deletion was success - self::assertGreaterThan(0, $crawler->filter('.successbox')->count()); - $this->assertContainsLang('TOPIC_PREFIX_DELETED', $crawler->text()); + $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . '&tags=' . $php_id . "&sid={$this->sid}"); + self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); + self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); } - /** - * Create a new topic prefix - * - * @param string $prefix_tag The name of the tag - * @param int $forum_id The forum identifier - * @return int The new topic prefix identifier - */ - protected function create_prefix($prefix_tag, $forum_id) + protected function create_tag($name, $color, array $forum_ids) { - $crawler = self::request('GET', 'adm/index.php?i=\phpbb\topicprefixes\acp\topic_prefixes_module&mode=manage&forum_id=' . self::FORUM_ID . "&sid={$this->sid}"); - + $crawler = $this->acp_page(); $form = $crawler->selectButton($this->lang('SUBMIT'))->form(array( - 'prefix_tag' => $prefix_tag, - 'forum_id' => $forum_id, + 'tag_name' => $name, + 'tag_color' => $color, + 'tag_enabled' => 1, + 'forum_ids' => $forum_ids, )); - /** @var Crawler $crawler */ $crawler = self::submit($form); + $this->assertContainsLang('TOPIC_TAG_SAVED', $crawler->text()); - // Assert new tag appears - self::assertStringContainsString($prefix_tag, $crawler->filter('table')->text()); - - // Get and return the new tag's id - $crawler = $crawler - ->filter('table > tbody > tr') - ->reduce(function (Crawler $node) use ($prefix_tag) { - return $node->filter('strong')->text() === $prefix_tag; - }); - $url = $crawler->filter('a')->first()->link()->getUri(); - - return (int) $this->get_parameter_from_link($url, 'prefix_id'); - } - - /** - * Create a link hash for the user 'admin' - * - * @param string $link_name The name of the link - * @return string the hash - */ - protected function mock_link_hash($link_name) - { $this->get_db(); - - $sql = "SELECT user_form_salt - FROM phpbb_users - WHERE username = 'admin'"; - $result = $this->db->sql_query($sql); - $user_form_salt = $this->db->sql_fetchfield('user_form_salt'); + $sql = "SELECT prefix_id FROM phpbb_topic_prefixes WHERE prefix_tag = '" . $this->db->sql_escape($name) . "' ORDER BY prefix_id DESC"; + $result = $this->db->sql_query_limit($sql, 1); + $tag_id = (int) $this->db->sql_fetchfield('prefix_id'); $this->db->sql_freeresult($result); - - return substr(sha1($user_form_salt . $link_name), 0, 8); + return $tag_id; } - public function test_posting() + protected function acp_page($params = '') { - $this->login(); - - $prefix = [ - 'id' => 1, - 'tag' => '[foo1]', - ]; - - // Check the new posting page - $crawler = self::request('GET', 'posting.php?mode=post&f=' . self::FORUM_ID . "&sid={$this->sid}"); - $this->assertContainsLang('TOPIC_PREFIX', $crawler->filter('#postingbox')->text()); - - // Check posting a new topic - $topic = $this->create_topic( - self::FORUM_ID, - "{$prefix['tag']} topic prefix test", - 'This is a test topic', - ['topic_prefix' => $prefix['id']] - ); - - // Check editing the topic page - $crawler = self::request('GET', 'posting.php?mode=edit&f=' . self::FORUM_ID . "&p={$topic['topic_id']}&sid={$this->sid}"); - $form = $crawler->selectButton($this->lang('SUBMIT'))->form(); - $values = $form->getValues(); - self::assertEquals($prefix['id'], $values['topic_prefix']); + $url = 'adm/index.php?i=\\phpbb\\topicprefixes\\acp\\topic_prefixes_module&mode=manage'; + if ($params !== '') + { + $url .= '&' . $params; + } + return self::request('GET', $url . "&sid={$this->sid}"); } } diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php new file mode 100644 index 0000000..9cb55ef --- /dev/null +++ b/tests/tags/renderer_test.php @@ -0,0 +1,22 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\tags; + +class renderer_test extends \phpbb_test_case +{ + public function test_automatic_contrast() + { + $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); + self::assertSame('#FFFFFF', $renderer->contrast_color('000000')); + self::assertSame('#000000', $renderer->contrast_color('FFFFFF')); + self::assertSame('#FFFFFF', $renderer->contrast_color('1D70B8')); + } +} From ce0dd54c7f445febb3eabce1904f3856fc3d5a21 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sat, 19 Sep 2026 14:19:38 -0700 Subject: [PATCH 02/32] Updates and tweaky fixes and more tests --- adm/style/acp_topic_prefixes.css | 6 - adm/style/acp_topic_prefixes.html | 4 +- controller/admin_controller.php | 2 +- event/display_listener.php | 40 ++- migrations/v200_data.php | 3 +- .../event/mcp_forum_topic_title_before.html | 1 + .../search_results_topic_title_after.html | 7 - .../search_results_topic_title_before.html | 1 + .../template/event/topiclist_row_prepend.html | 12 +- .../event/viewtopic_topic_title_append.html | 7 - .../event/viewtopic_topic_title_prepend.html | 1 + styles/all/template/topic_tag_badges.html | 7 + styles/all/theme/topic_tags.css | 2 - tags/manager.php | 13 +- tests/controller/admin_controller_test.php | 291 ++++++++++++++++++ tests/dbal/assignment_manager_test.php | 18 ++ tests/dbal/filter_test.php | 31 ++ tests/dbal/tag_manager_test.php | 35 ++- tests/event/display_listener_test.php | 185 +++++++++++ tests/event/posting_listener_test.php | 82 +++++ tests/event/viewforum_listener_test.php | 75 ++++- tests/functional/functional_test.php | 8 + tests/tags/renderer_test.php | 52 ++++ 23 files changed, 836 insertions(+), 47 deletions(-) create mode 100644 styles/all/template/event/mcp_forum_topic_title_before.html delete mode 100644 styles/all/template/event/search_results_topic_title_after.html create mode 100644 styles/all/template/event/search_results_topic_title_before.html delete mode 100644 styles/all/template/event/viewtopic_topic_title_append.html create mode 100644 styles/all/template/event/viewtopic_topic_title_prepend.html create mode 100644 styles/all/template/topic_tag_badges.html create mode 100644 tests/event/display_listener_test.php diff --git a/adm/style/acp_topic_prefixes.css b/adm/style/acp_topic_prefixes.css index 35e1aa4..0f3faa4 100644 --- a/adm/style/acp_topic_prefixes.css +++ b/adm/style/acp_topic_prefixes.css @@ -23,12 +23,6 @@ text-align: center; } -.topic-tag-forums { - list-style: disc; - margin: 0 0 0 1.25em; - padding: 0; -} - #forum_ids { min-width: 22em; } diff --git a/adm/style/acp_topic_prefixes.html b/adm/style/acp_topic_prefixes.html index cd01a1d..8d70158 100644 --- a/adm/style/acp_topic_prefixes.html +++ b/adm/style/acp_topic_prefixes.html @@ -20,9 +20,7 @@

{{ lang('TOPIC_TAGS') }}

{{ tag.TAG_NAME }} {% if tag.FORUM_NAMES %} -
    - {% for forum_name in tag.FORUM_NAMES %}
  • {{ forum_name }}
  • {% endfor %} -
+ {% for forum_name in tag.FORUM_NAMES %}{{ forum_name }}{% if not loop.last %}
{% endif %}{% endfor %} {% else %} {{ lang('TOPIC_TAG_NO_FORUMS') }} {% endif %} diff --git a/controller/admin_controller.php b/controller/admin_controller.php index 9764777..bf42dd0 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -182,7 +182,7 @@ public function save_tag(int $tag_id): void { $this->trigger_message('TOPIC_TAG_NAME_REQUIRED', E_USER_WARNING); } - if ($this->manager->normalize_color($color) === false) + if ($this->manager->normalize_color($color) === '') { $this->trigger_message('TOPIC_TAG_COLOR_INVALID', E_USER_WARNING); } diff --git a/event/display_listener.php b/event/display_listener.php index b72cbdf..df63553 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -16,7 +16,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** - * Display topic tags on viewtopic and search result pages. + * Display topic tags on topic headings and topic-list pages outside viewforum. */ class display_listener implements EventSubscriberInterface { @@ -32,6 +32,9 @@ class display_listener implements EventSubscriberInterface /** @var array Tags grouped by search result topic */ protected $search_tags = []; + /** @var array Tags grouped by MCP topic */ + protected $mcp_tags = []; + /** * Constructor. * @@ -55,6 +58,8 @@ public static function getSubscribedEvents(): array 'core.viewtopic_assign_template_vars_before' => 'add_viewtopic_tags', 'core.search_modify_rowset' => 'load_search_tags', 'core.search_modify_tpl_ary' => 'add_search_tags', + 'core.mcp_forum_topic_data_modify_sql' => 'load_mcp_tags', + 'core.mcp_view_forum_modify_topicrow' => 'add_mcp_tags', ]; } @@ -69,7 +74,10 @@ public function add_viewtopic_tags($event): void $topic_id = (int) $event['topic_id']; $tags = $this->assignments->get_tags_for_topics([$topic_id]); $topic_tags = $tags[$topic_id] ?? []; - $this->template->assign_var('TOPIC_TAGS', $this->renderer->render($topic_tags, (int) $event['forum_id'])); + $this->template->assign_block_vars_array( + 'topic_tags', + $this->renderer->render($topic_tags, (int) $event['forum_id']) + ); } /** @@ -102,4 +110,32 @@ public function add_search_tags($event): void $tpl['TOPIC_TAGS'] = $this->renderer->render($tags, (int) $event['row']['forum_id']); $event['tpl_ary'] = $tpl; } + + /** + * Batch-load tags for topics displayed in MCP forum view. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function load_mcp_tags($event): void + { + $this->mcp_tags = $this->assignments->get_tags_for_topics($event['topic_list']); + } + + /** + * Add tag badge data to one MCP forum topic row. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_mcp_tags($event): void + { + $topic_id = (int) $event['row']['topic_id']; + $topic_row = $event['topic_row']; + $topic_row['MCP_TOPIC_TAGS'] = $this->renderer->render( + $this->mcp_tags[$topic_id] ?? [], + (int) $event['row']['forum_id'] + ); + $event['topic_row'] = $topic_row; + } } diff --git a/migrations/v200_data.php b/migrations/v200_data.php index 560f2f2..5aa5884 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -267,8 +267,7 @@ protected function bulk_update_text(string $table, string $id_column, string $va } $sql = 'UPDATE ' . $table . ' - SET ' . $value_column . ' = CASE ' . $id_column . ' - ' . implode("\n\t\t\t\t", $cases) . ' + SET ' . $value_column . ' = CASE ' . $id_column . ' ' . implode(' ', $cases) . ' ELSE ' . $value_column . ' END WHERE ' . $this->db->sql_in_set($id_column, array_keys($changes)); diff --git a/styles/all/template/event/mcp_forum_topic_title_before.html b/styles/all/template/event/mcp_forum_topic_title_before.html new file mode 100644 index 0000000..8fbff9a --- /dev/null +++ b/styles/all/template/event/mcp_forum_topic_title_before.html @@ -0,0 +1 @@ +{% include '@phpbb_topicprefixes/topic_tag_badges.html' with {'tags': topicrow.MCP_TOPIC_TAGS, 'badge_class': 'topic-row-tags'} only %} diff --git a/styles/all/template/event/search_results_topic_title_after.html b/styles/all/template/event/search_results_topic_title_after.html deleted file mode 100644 index 55cb38a..0000000 --- a/styles/all/template/event/search_results_topic_title_after.html +++ /dev/null @@ -1,7 +0,0 @@ -{% if searchresults.TOPIC_TAGS %} -
- {% for tag in searchresults.TOPIC_TAGS %} - {{ tag.TAG_NAME }} - {% endfor %} -
-{% endif %} diff --git a/styles/all/template/event/search_results_topic_title_before.html b/styles/all/template/event/search_results_topic_title_before.html new file mode 100644 index 0000000..0f68f26 --- /dev/null +++ b/styles/all/template/event/search_results_topic_title_before.html @@ -0,0 +1 @@ +{% include '@phpbb_topicprefixes/topic_tag_badges.html' with {'tags': searchresults.TOPIC_TAGS, 'badge_class': 'topic-row-tags'} only %} diff --git a/styles/all/template/event/topiclist_row_prepend.html b/styles/all/template/event/topiclist_row_prepend.html index aa53241..6f46bdd 100644 --- a/styles/all/template/event/topiclist_row_prepend.html +++ b/styles/all/template/event/topiclist_row_prepend.html @@ -1,13 +1,5 @@ {% if topicrow.TOPIC_TAGS %} - - {% for tag in topicrow.TOPIC_TAGS %} - {{ tag.TAG_NAME }} - {% endfor %} - + {% include '@phpbb_topicprefixes/topic_tag_badges.html' with {'tags': topicrow.TOPIC_TAGS, 'badge_class': 'topic-row-tags'} only %} {% elseif searchresults.TOPIC_TAGS %} - - {% for tag in searchresults.TOPIC_TAGS %} - {{ tag.TAG_NAME }} - {% endfor %} - + {% include '@phpbb_topicprefixes/topic_tag_badges.html' with {'tags': searchresults.TOPIC_TAGS, 'badge_class': 'topic-row-tags'} only %} {% endif %} diff --git a/styles/all/template/event/viewtopic_topic_title_append.html b/styles/all/template/event/viewtopic_topic_title_append.html deleted file mode 100644 index 2315d64..0000000 --- a/styles/all/template/event/viewtopic_topic_title_append.html +++ /dev/null @@ -1,7 +0,0 @@ -{% if TOPIC_TAGS %} - - {% for tag in TOPIC_TAGS %} - {{ tag.TAG_NAME }} - {% endfor %} - -{% endif %} diff --git a/styles/all/template/event/viewtopic_topic_title_prepend.html b/styles/all/template/event/viewtopic_topic_title_prepend.html new file mode 100644 index 0000000..063e6a1 --- /dev/null +++ b/styles/all/template/event/viewtopic_topic_title_prepend.html @@ -0,0 +1 @@ +{% include '@phpbb_topicprefixes/topic_tag_badges.html' with {'tags': loops.topic_tags, 'badge_class': 'viewtopic-tags'} only %} diff --git a/styles/all/template/topic_tag_badges.html b/styles/all/template/topic_tag_badges.html new file mode 100644 index 0000000..21bc7b0 --- /dev/null +++ b/styles/all/template/topic_tag_badges.html @@ -0,0 +1,7 @@ +{% if tags %} + + {% for tag in tags %} + {{ tag.TAG_NAME }} + {% endfor %} + +{% endif %} diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css index b7fa37d..45a899b 100644 --- a/styles/all/theme/topic_tags.css +++ b/styles/all/theme/topic_tags.css @@ -68,12 +68,10 @@ a.topic-tag:focus { } .topic-row-tags { - margin-right: 5px; vertical-align: 1px; } .viewtopic-tags { font-size: 0.7em; - margin-left: 8px; vertical-align: middle; } diff --git a/tags/manager.php b/tags/manager.php index e35ae9a..ff366f1 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -6,6 +6,9 @@ * @copyright (c) 2016 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * + * @noinspection UnnecessaryCastingInspection + * @noinspection PhpCastIsUnnecessaryInspection + * @noinspection PhpRedundantOptionalArgumentInspection */ namespace phpbb\topicprefixes\tags; @@ -170,7 +173,7 @@ public function add_tag(string $name, string $color, bool $enabled, array $forum { $name = trim($name); $color = $this->normalize_color($color); - if ($name === '' || $color === false) + if ($name === '' || $color === '') { return false; } @@ -210,7 +213,7 @@ public function update_tag(int $tag_id, string $name, string $color, bool $enabl $tag_id = (int) $tag_id; $name = trim($name); $color = $this->normalize_color($color); - if (!$tag_id || $name === '' || $color === false || !$this->tag_exists($tag_id)) + if (!$tag_id || $name === '' || $color === '' || !$this->tag_exists($tag_id)) { return false; } @@ -374,12 +377,12 @@ public function get_forum_names_by_tag(): array * Normalize and validate hexadecimal color. * * @param string $color Submitted color - * @return string|false Normalized color, or false when invalid + * @return string Normalized color, or empty string when invalid */ - public function normalize_color(string $color) + public function normalize_color(string $color): string { $color = strtoupper(ltrim(trim($color), '#')); - return preg_match('/^[0-9A-F]{6}$/D', $color) ? $color : false; + return preg_match('/^[0-9A-F]{6}$/D', $color) ? $color : ''; } /** diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index 11cab38..cb779e8 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -15,7 +15,10 @@ class admin_controller_test extends \phpbb_test_case { public static $valid_hash = true; + public static $valid_form = true; public static $confirmed = false; + public static $triggered_message; + public static $throw_on_trigger = false; protected $manager; protected $renderer; @@ -33,12 +36,19 @@ protected function setUp(): void $this->manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); $this->renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); $this->language = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); + $this->language->add_lang('acp_topic_prefixes', 'phpbb/topicprefixes'); $this->log = $this->getMockBuilder('\phpbb\log\log')->disableOriginalConstructor()->getMock(); $this->request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); $this->template = $this->getMockBuilder('\phpbb\template\template')->disableOriginalConstructor()->getMock(); $this->user = $this->getMockBuilder('\phpbb\user')->disableOriginalConstructor()->getMock(); $this->user->data = array('user_id' => 2, 'user_form_salt' => 'salt'); $this->user->ip = '127.0.0.1'; + $this->user->lang = array('BACK_TO_PREV' => 'Back'); + self::$valid_hash = true; + self::$valid_form = true; + self::$confirmed = false; + self::$triggered_message = null; + self::$throw_on_trigger = false; $user = $this->user; $this->controller = new admin_controller( $this->manager, $this->renderer, $this->language, $this->log, $this->request, $this->template, $this->user @@ -86,6 +96,287 @@ public function test_delete_requires_confirmation() $this->manager->expects(self::never())->method('delete_tag'); $this->controller->delete_tag(1); } + + /** + * Test creating a tag validates input, logs, and reports success. + */ + public function test_save_creates_tag(): void + { + $this->request->method('is_set_post')->with('submit')->willReturn(true); + $this->request->method('variable')->willReturnCallback(function ($name, $default) { + return array( + 'tag_name' => 'Security', + 'tag_color' => '#AA00CC', + 'tag_enabled' => 1, + 'forum_ids' => array(2, 3), + )[$name] ?? $default; + }); + $this->manager->expects(self::once())->method('normalize_color')->with('#AA00CC')->willReturn('AA00CC'); + $this->manager->expects(self::once()) + ->method('add_tag') + ->with('Security', '#AA00CC', 1, array(2, 3)) + ->willReturn(array('prefix_tag' => 'Security')); + $this->log->expects(self::once())->method('add')->with( + 'admin', 2, '127.0.0.1', 'ACP_LOG_TAG_ADDED', self::isType('int'), array('Security') + ); + + $this->controller->save_tag(0); + + self::assertStringContainsString('TOPIC_TAG_SAVED', self::$triggered_message); + } + + /** + * Test saving an existing tag uses update path. + */ + public function test_save_updates_tag(): void + { + $this->request->method('is_set_post')->with('submit')->willReturn(true); + $this->request->method('variable')->willReturnCallback(function ($name, $default) { + return array( + 'tag_name' => 'Updated', + 'tag_color' => '#00AA00', + 'tag_enabled' => 0, + 'forum_ids' => array(3), + )[$name] ?? $default; + }); + $this->manager->method('normalize_color')->willReturn('00AA00'); + $this->manager->expects(self::once()) + ->method('update_tag') + ->with(1, 'Updated', '#00AA00', 0, array(3)) + ->willReturn(array('prefix_tag' => 'Updated')); + $this->log->expects(self::once())->method('add')->with( + 'admin', 2, '127.0.0.1', 'ACP_LOG_TAG_UPDATED', self::isType('int'), array('Updated') + ); + + $this->controller->save_tag(1); + + self::assertStringContainsString('TOPIC_TAG_SAVED', self::$triggered_message); + } + + /** + * Test confirmed deletion removes tag and writes ACP log. + */ + public function test_confirmed_delete_removes_tag(): void + { + self::$confirmed = true; + $this->manager->method('get_tag')->with(1)->willReturn(array('prefix_tag' => 'Bug')); + $this->manager->expects(self::once())->method('delete_tag')->with(1); + $this->log->expects(self::once())->method('add')->with( + 'admin', 2, '127.0.0.1', 'ACP_LOG_TAG_DELETED', self::isType('int'), array('Bug') + ); + + $this->controller->delete_tag(1); + + self::assertStringContainsString('TOPIC_TAG_DELETED', self::$triggered_message); + } + + /** + * Test main action dispatch loads edit form. + */ + public function test_main_dispatches_edit_action(): void + { + $tag = array( + 'prefix_id' => 1, + 'prefix_tag' => 'Bug', + 'prefix_color' => 'D4351C', + 'prefix_enabled' => 1, + 'forum_ids' => array(2), + ); + $this->request->method('variable')->willReturnCallback(function ($name, $default) { + return array('action' => 'edit', 'tag_id' => 1)[$name] ?? $default; + }); + $this->manager->expects(self::once())->method('get_tag')->with(1)->willReturn($tag); + $this->manager->method('get_forum_names_by_tag')->willReturn(array()); + $this->manager->method('get_tags')->willReturn(array()); + $this->template->expects(self::once())->method('assign_vars')->with(self::callback(function ($vars) { + return $vars['S_EDIT_TAG'] && $vars['TAG_ID'] === 1; + })); + + $this->controller->main(); + } + + /** + * Test edit action rejects missing tag records. + */ + public function test_main_rejects_missing_edit_tag(): void + { + self::$throw_on_trigger = true; + $this->request->method('variable')->willReturnCallback(function ($name, $default) { + return array('action' => 'edit', 'tag_id' => 999)[$name] ?? $default; + }); + $this->manager->method('get_tag')->with(999)->willReturn(false); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TOPIC_TAG_NOT_FOUND'); + + $this->controller->main(); + } + + /** + * Provide non-edit ACP action routing cases. + * + * @return array Action, expected method, and expected arguments + */ + public function main_action_data(): array + { + return array( + array('save', 'save_tag', array(2)), + array('delete', 'delete_tag', array(2)), + array('toggle', 'toggle_tag', array(2)), + array('move_up', 'move_tag', array(2, 'up')), + array('move_down', 'move_tag', array(2, 'down')), + ); + } + + /** + * Test main routes each ACP action to focused handler. + * + * @dataProvider main_action_data + */ + public function test_main_routes_actions(string $action, string $method, array $arguments): void + { + $this->request->method('variable')->willReturnCallback(function ($name, $default) use ($action) { + return array('action' => $action, 'tag_id' => 2)[$name] ?? $default; + }); + $controller = $this->getMockBuilder(admin_controller::class) + ->setConstructorArgs(array( + $this->manager, $this->renderer, $this->language, $this->log, + $this->request, $this->template, $this->user, + )) + ->setMethods(array('save_tag', 'delete_tag', 'toggle_tag', 'move_tag', 'display_settings')) + ->getMock(); + $controller->expects(self::once())->method($method)->with(...$arguments); + $controller->expects(self::once())->method('display_settings')->with(false); + + $controller->main(); + } + + /** + * Test invalid form submission stops before tag writes. + */ + public function test_save_rejects_invalid_form(): void + { + self::$valid_form = false; + self::$throw_on_trigger = true; + $this->request->method('is_set_post')->with('submit')->willReturn(true); + $this->manager->expects(self::never())->method('add_tag'); + $this->expectException(\RuntimeException::class); + + $this->controller->save_tag(0); + } + + /** + * Provide ACP validation and missing-record error cases. + * + * @return array Scenario and expected language key + */ + public function error_path_data(): array + { + return array( + array('missing_name', 'TOPIC_TAG_NAME_REQUIRED'), + array('invalid_color', 'TOPIC_TAG_COLOR_INVALID'), + array('failed_update', 'TOPIC_TAG_NOT_FOUND'), + array('missing_delete', 'TOPIC_TAG_NOT_FOUND'), + array('invalid_toggle_hash', 'submitted form was invalid'), + array('missing_toggle', 'TOPIC_TAG_NOT_FOUND'), + array('invalid_move_hash', 'submitted form was invalid'), + array('missing_move', 'TOPIC_TAG_NOT_FOUND'), + ); + } + + /** + * Test ACP handlers stop on invalid input or missing records. + * + * @dataProvider error_path_data + */ + public function test_error_paths(string $scenario, string $message): void + { + self::$throw_on_trigger = true; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + + switch ($scenario) + { + case 'missing_name': + $this->configure_save_request('', '#AA00CC'); + $this->controller->save_tag(0); + break; + + case 'invalid_color': + $this->configure_save_request('Bug', 'invalid'); + $this->manager->method('normalize_color')->willReturn(''); + $this->controller->save_tag(0); + break; + + case 'failed_update': + $this->configure_save_request('Bug', '#AA00CC'); + $this->manager->method('normalize_color')->willReturn('AA00CC'); + $this->manager->method('update_tag')->willReturn(false); + $this->controller->save_tag(999); + break; + + case 'missing_delete': + $this->manager->method('get_tag')->willReturn(false); + $this->controller->delete_tag(999); + break; + + case 'invalid_toggle_hash': + self::$valid_hash = false; + $this->controller->toggle_tag(999); + break; + + case 'missing_toggle': + $this->manager->method('get_tag')->willReturn(false); + $this->controller->toggle_tag(999); + break; + + case 'invalid_move_hash': + self::$valid_hash = false; + $this->controller->move_tag(999, 'up'); + break; + + case 'missing_move': + $this->manager->method('move_tag')->willReturn(false); + $this->controller->move_tag(999, 'up'); + break; + } + } + + /** + * Configure valid form request values for save error tests. + * + * @param string $name Tag name + * @param string $color Tag color + */ + protected function configure_save_request(string $name, string $color): void + { + $this->request->method('is_set_post')->with('submit')->willReturn(true); + $this->request->method('variable')->willReturnCallback(function ($key, $default) use ($name, $color) { + return array( + 'tag_name' => $name, + 'tag_color' => $color, + 'tag_enabled' => 1, + 'forum_ids' => array(2), + )[$key] ?? $default; + }); + } +} + +function add_form_key() +{ +} + +function check_form_key() +{ + return admin_controller_test::$valid_form; +} + +function trigger_error($message, $error = E_USER_NOTICE) +{ + admin_controller_test::$triggered_message = $message; + if (admin_controller_test::$throw_on_trigger) + { + throw new \RuntimeException($message, $error); + } } function make_forum_select() diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 5ad6b9b..d30be27 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -14,6 +14,9 @@ class assignment_manager_test extends tags_base { + /** + * Test replacing, removing, and deduplicating topic tags. + */ public function test_assign_multiple_edit_remove_and_deduplicate() { $manager = $this->create_assignment_manager(); @@ -27,6 +30,9 @@ public function test_assign_multiple_edit_remove_and_deduplicate() self::assertSame(array(), $manager->get_topic_tag_ids(13)); } + /** + * Test batch tag retrieval. + */ public function test_batch_retrieval() { $tags = $this->create_assignment_manager()->get_tags_for_topics(array(10, 11, 13)); @@ -34,4 +40,16 @@ public function test_batch_retrieval() self::assertSame(array(1), array_keys($tags[11])); self::assertArrayNotHasKey(13, $tags); } + + /** + * Test invalid and empty identifiers avoid relationship queries. + */ + public function test_empty_identifiers_are_rejected(): void + { + $manager = $this->create_assignment_manager(); + + self::assertFalse($manager->set_topic_tags(0, [1])); + self::assertSame([], $manager->get_tags_for_topics([0, 0])); + self::assertSame([], $manager->get_topic_tag_ids(999)); + } } diff --git a/tests/dbal/filter_test.php b/tests/dbal/filter_test.php index c71a092..2c3554b 100644 --- a/tests/dbal/filter_test.php +++ b/tests/dbal/filter_test.php @@ -14,6 +14,11 @@ class filter_test extends tags_base { + /** + * Create filter with unrestricted content visibility. + * + * @return \phpbb\topicprefixes\tags\filter + */ protected function create_filter() { $visibility = $this->getMockBuilder('\phpbb\content_visibility')->disableOriginalConstructor()->getMock(); @@ -21,22 +26,48 @@ protected function create_filter() return new \phpbb\topicprefixes\tags\filter($this->db, $visibility, 'phpbb_topic_prefixes_topics', 'phpbb_topics'); } + /** + * Test filtering by one tag. + */ public function test_single_tag_filter() { self::assertSame(array(10, 11), $this->query_ids(array(1))); } + /** + * Test multiple selected tags use AND semantics. + */ public function test_multiple_tags_use_and_semantics_without_duplicates() { self::assertSame(array(10), $this->query_ids(array(1, 2))); } + /** + * Test filtered count matches result rows. + */ public function test_filtered_count_matches_rows() { self::assertSame(2, $this->create_filter()->count_topics(2, array(1))); self::assertSame(1, $this->create_filter()->count_topics(2, array(1, 2))); } + /** + * Test empty filters and topic-age count condition. + */ + public function test_empty_filter_and_sort_days(): void + { + $filter = $this->create_filter(); + + self::assertSame('1=1', $filter->condition('t', [0, 0])); + self::assertSame(2, $filter->count_topics(2, [1], 7)); + } + + /** + * Query topic identifiers matching tags. + * + * @param array $tag_ids Tag identifiers + * @return array Topic identifiers + */ protected function query_ids(array $tag_ids) { $sql = 'SELECT t.topic_id FROM phpbb_topics t WHERE t.forum_id = 2 AND ' . $this->create_filter()->condition('t', $tag_ids) . ' ORDER BY t.topic_id'; diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index f3950ee..4bd438d 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -76,6 +76,39 @@ public function test_color_validation() { $manager = $this->create_tag_manager(); self::assertSame('AABBCC', $manager->normalize_color('#aabbcc')); - self::assertFalse($manager->normalize_color('red')); + self::assertSame('', $manager->normalize_color('red')); + } + + /** + * Test invalid writes and missing tag operations. + */ + public function test_invalid_and_missing_tag_operations(): void + { + $manager = $this->create_tag_manager(); + + self::assertFalse($manager->add_tag('', 'FFFFFF', true, [2])); + self::assertFalse($manager->add_tag('Invalid color', 'red', true, [2])); + self::assertFalse($manager->update_tag(999, 'Missing', 'FFFFFF', true, [2])); + self::assertFalse($manager->set_enabled(999, true)); + self::assertFalse($manager->delete_tag(999)); + self::assertFalse($manager->move_tag(999, 'up')); + self::assertTrue($manager->move_tag(1, 'up')); + self::assertSame([], $manager->get_assignable_tags(2, [])); + } + + /** + * Test enabled state and ACP forum name lookup. + */ + public function test_enabled_state_and_forum_names(): void + { + $manager = $this->create_tag_manager(); + self::assertTrue($manager->set_enabled(1, false)); + self::assertArrayNotHasKey(1, $manager->get_available_tags(2)); + self::assertSame([ + 1 => ['Forum Two', 'Forum Three'], + 2 => ['Forum Two'], + 3 => ['Forum Two'], + 4 => ['Forum Three'], + ], $manager->get_forum_names_by_tag()); } } diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php new file mode 100644 index 0000000..abf1ed7 --- /dev/null +++ b/tests/event/display_listener_test.php @@ -0,0 +1,185 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\event; + +class display_listener_test extends \phpbb_test_case +{ + /** + * Test listener subscribes to all display events. + */ + public function test_subscribed_events(): void + { + self::assertSame([ + 'core.viewtopic_assign_template_vars_before', + 'core.search_modify_rowset', + 'core.search_modify_tpl_ary', + 'core.mcp_forum_topic_data_modify_sql', + 'core.mcp_view_forum_modify_topicrow', + ], array_keys(\phpbb\topicprefixes\event\display_listener::getSubscribedEvents())); + } + + /** + * Test viewtopic tags are assigned through template block API. + */ + public function test_viewtopic_tags_use_template_block(): void + { + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer') + ->disableOriginalConstructor() + ->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template') + ->getMock(); + + $tag = [ + 'prefix_id' => 1, + 'prefix_tag' => 'Bug', + 'prefix_color' => 'D4351C', + ]; + $rendered = [[ + 'TAG_ID' => 1, + 'TAG_NAME' => 'Bug', + 'TAG_COLOR' => '#D4351C', + ]]; + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([42]) + ->willReturn([42 => [1 => $tag]]); + $renderer->expects(self::once()) + ->method('render') + ->with([1 => $tag], 2) + ->willReturn($rendered); + $template->expects(self::once()) + ->method('assign_block_vars_array') + ->with('topic_tags', $rendered); + $template->expects(self::never()) + ->method('assign_var'); + + $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $event = new \phpbb\event\data(['topic_id' => 42, 'forum_id' => 2]); + $listener->add_viewtopic_tags($event); + } + + /** + * Test search tags are batch-loaded and assigned to result rows. + */ + public function test_search_tags_are_batch_loaded_and_rendered(): void + { + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer') + ->disableOriginalConstructor() + ->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->getMock(); + $tag = ['prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C']; + $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([42, 43]) + ->willReturn([42 => [1 => $tag]]); + $renderer->expects(self::once()) + ->method('render') + ->with([1 => $tag], 2) + ->willReturn($rendered); + + $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener->load_search_tags(new \phpbb\event\data([ + 'rowset' => [['topic_id' => 42], ['topic_id' => 43]], + 'show_results' => 'topics', + ])); + $event = new \phpbb\event\data([ + 'row' => ['topic_id' => 42, 'forum_id' => 2], + 'tpl_ary' => ['TOPIC_TITLE' => 'Tagged topic'], + 'show_results' => 'topics', + ]); + $listener->add_search_tags($event); + + self::assertSame($rendered, $event['tpl_ary']['TOPIC_TAGS']); + } + + /** + * Test post-mode search results receive their topic tags. + */ + public function test_search_post_tags_are_batch_loaded_and_rendered(): void + { + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer') + ->disableOriginalConstructor() + ->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->getMock(); + $tag = ['prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C']; + $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([42]) + ->willReturn([42 => [1 => $tag]]); + $renderer->expects(self::once()) + ->method('render') + ->with([1 => $tag], 2) + ->willReturn($rendered); + + $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener->load_search_tags(new \phpbb\event\data([ + 'rowset' => [['topic_id' => 42]], + 'show_results' => 'posts', + ])); + $event = new \phpbb\event\data([ + 'row' => ['topic_id' => 42, 'forum_id' => 2], + 'tpl_ary' => ['TOPIC_TITLE' => 'Tagged topic'], + 'show_results' => 'posts', + ]); + $listener->add_search_tags($event); + + self::assertSame($rendered, $event['tpl_ary']['TOPIC_TAGS']); + } + + /** + * Test MCP topic tags are batch-loaded and rendered. + */ + public function test_mcp_tags_are_batch_loaded_and_rendered(): void + { + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer') + ->disableOriginalConstructor() + ->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->getMock(); + $tag = ['prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C']; + $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([42, 43]) + ->willReturn([42 => [1 => $tag]]); + $renderer->expects(self::once()) + ->method('render') + ->with([1 => $tag], 2) + ->willReturn($rendered); + + $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener->load_mcp_tags(new \phpbb\event\data(['topic_list' => [42, 43]])); + $event = new \phpbb\event\data([ + 'row' => ['topic_id' => 42, 'forum_id' => 2], + 'topic_row' => ['TOPIC_TITLE' => 'Tagged topic'], + ]); + $listener->add_mcp_tags($event); + + self::assertSame($rendered, $event['topic_row']['MCP_TOPIC_TAGS']); + } +} diff --git a/tests/event/posting_listener_test.php b/tests/event/posting_listener_test.php index 75272d1..3ef053d 100644 --- a/tests/event/posting_listener_test.php +++ b/tests/event/posting_listener_test.php @@ -95,6 +95,11 @@ public function test_valid_submission_saves_relationships_without_changing_subje $this->manager->method('get_assignable_tags')->willReturn($this->tags()); $this->assignments->expects(self::once())->method('set_topic_tags')->with(42, array(1, 2)); $listener = $this->listener(); + $validation = new \phpbb\event\data(array( + 'submit' => true, 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), 'error' => array(), + )); + $listener->validate_submission($validation); + self::assertSame(array(), $validation['error']); $before = new \phpbb\event\data(array( 'mode' => 'post', 'forum_id' => 2, 'post_data' => array('post_subject' => 'Plain title'), )); @@ -103,6 +108,83 @@ public function test_valid_submission_saves_relationships_without_changing_subje $listener->save_assignments(new \phpbb\event\data(array('data' => array('topic_id' => 42)))); } + /** + * Test capture validates tags when submission-error event was not invoked. + */ + public function test_capture_validates_when_needed(): void + { + $this->request->method('is_set_post')->willReturn(true); + $this->request->method('variable')->willReturn(array(1, 2)); + $this->manager->expects(self::once()) + ->method('get_assignable_tags') + ->with(2, array(1, 2)) + ->willReturn($this->tags()); + $this->assignments->expects(self::once())->method('set_topic_tags')->with(42, array(1, 2)); + $listener = $this->listener(); + + $listener->capture_submission(new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), + ))); + $listener->save_assignments(new \phpbb\event\data(array('data' => array('topic_id' => 42)))); + } + + /** + * Test absent controls and reply submissions are ignored. + */ + public function test_non_topic_or_absent_tag_submission_is_ignored(): void + { + $this->request->method('is_set_post')->willReturn(false); + $this->manager->expects(self::never())->method('get_assignable_tags'); + $this->assignments->expects(self::never())->method('set_topic_tags'); + $listener = $this->listener(); + + $listener->validate_submission(new \phpbb\event\data(array( + 'submit' => false, 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), 'error' => array(), + ))); + $listener->validate_submission(new \phpbb\event\data(array( + 'submit' => true, 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), 'error' => array(), + ))); + $listener->capture_submission(new \phpbb\event\data(array( + 'mode' => 'reply', 'forum_id' => 2, 'post_data' => array(), + ))); + $listener->capture_submission(new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), + ))); + $listener->save_assignments(new \phpbb\event\data(array('data' => array('topic_id' => 42)))); + } + + /** + * Test topic form is hidden when forum has no available tags. + */ + public function test_topic_form_without_available_tags_is_untouched(): void + { + $this->manager->method('get_available_tags')->willReturn(array()); + $event = new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'topic_id' => 0, 'post_data' => array(), 'page_data' => array(), + )); + + $this->listener()->add_to_posting_form($event); + + self::assertSame(array(), $event['page_data']); + } + + /** + * Test fresh topic form defaults to no selected tags. + */ + public function test_new_topic_defaults_to_no_selected_tags(): void + { + $this->manager->method('get_available_tags')->willReturn($this->tags()); + $this->request->method('is_set_post')->willReturn(false); + $event = new \phpbb\event\data(array( + 'mode' => 'post', 'forum_id' => 2, 'topic_id' => 0, 'post_data' => array(), 'page_data' => array(), + )); + + $this->listener()->add_to_posting_form($event); + + self::assertFalse($event['page_data']['TOPIC_TAGS'][0]['S_SELECTED']); + self::assertFalse($event['page_data']['TOPIC_TAGS'][1]['S_SELECTED']); + } + protected function listener() { return new \phpbb\topicprefixes\event\posting_listener( diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index d144696..5c549c3 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -12,6 +12,24 @@ class viewforum_listener_test extends \phpbb_test_case { + /** + * Test listener event subscriptions. + */ + public function test_subscribed_events(): void + { + self::assertSame(array( + 'core.viewforum_get_topic_data', + 'core.viewforum_get_announcement_topic_ids_data', + 'core.viewforum_get_topic_ids_data', + 'core.viewforum_modify_topics_data', + 'core.viewforum_modify_topicrow', + 'core.pagination_generate_page_link', + ), array_keys(\phpbb\topicprefixes\event\viewforum_listener::getSubscribedEvents())); + } + + /** + * Test filter queries, counts, row rendering, and pagination. + */ public function test_filter_modifies_ids_count_and_pagination() { $manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); @@ -25,7 +43,9 @@ public function test_filter_modifies_ids_count_and_pagination() 1 => array('prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_order' => 1), 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8', 'prefix_order' => 2), ); - $manager->method('get_available_tags')->willReturn($tags); + $manager->method('get_available_tags')->willReturnCallback(function ($forum_id, $enabled_only = true) use ($tags) { + return $enabled_only ? array(1 => $tags[1]) : $tags; + }); $request->method('variable')->with('tags', '')->willReturn('1,2'); $filter->expects(self::once())->method('count_topics')->with(2, array(1, 2), 0)->willReturn(7); $filter->method('condition')->willReturn('FILTER_CONDITION'); @@ -44,8 +64,61 @@ public function test_filter_modifies_ids_count_and_pagination() $listener->filter_topic_ids($ids); self::assertSame('t.forum_id = 2 AND FILTER_CONDITION', $ids['sql_ary']['WHERE']); + $announcements = new \phpbb\event\data(array('sql_ary' => array('WHERE' => 't.topic_type = 3'))); + $listener->filter_announcements($announcements); + self::assertSame('(t.topic_type = 3) AND FILTER_CONDITION', $announcements['sql_ary']['WHERE']); + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with(array(10, 11)) + ->willReturn(array(10 => array(1 => $tags[1]))); + $listener->load_topic_tags(new \phpbb\event\data(array( + 'rowset' => array(array('topic_id' => 10), array('topic_id' => 11)), + ))); + $row = new \phpbb\event\data(array( + 'row' => array('topic_id' => 10), + 'topic_row' => array('TOPIC_TITLE' => 'Tagged topic'), + )); + $listener->add_topic_tags($row); + self::assertArrayHasKey('TOPIC_TAGS', $row['topic_row']); + $page = new \phpbb\event\data(array('base_url' => './viewforum.php?f=2', 'on_page' => 2, 'start_name' => 'start', 'per_page' => 25, 'generate_page_link_override' => false)); $listener->preserve_pagination_filter($page); self::assertSame('./viewforum.php?f=2&tags=1,2', $page['base_url']); + + $existing = new \phpbb\event\data(array('base_url' => './viewforum.php?f=2&tags=1,2')); + $listener->preserve_pagination_filter($existing); + self::assertSame('./viewforum.php?f=2&tags=1,2', $existing['base_url']); + } + + /** + * Test invalid request values leave phpBB queries unchanged. + */ + public function test_invalid_filter_is_ignored(): void + { + $manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager')->disableOriginalConstructor()->getMock(); + $filter = $this->getMockBuilder('\phpbb\topicprefixes\tags\filter')->disableOriginalConstructor()->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); + $request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->disableOriginalConstructor()->getMock(); + $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); + + $manager->method('get_available_tags')->willReturn(array()); + $request->method('variable')->with('tags', '')->willReturn('1,invalid'); + $filter->expects(self::never())->method('count_topics'); + $renderer->method('render')->willReturn(array()); + $renderer->method('filter_url')->willReturn('./viewforum.php?f=2'); + $listener = new \phpbb\topicprefixes\event\viewforum_listener( + $manager, $assignments, $filter, $renderer, $request, $template, $language + ); + $listener->configure_filter(new \phpbb\event\data(array( + 'forum_id' => 2, 'topics_count' => 20, 'sort_days' => 0, 'sort_key' => 't', 'sort_dir' => 'd', + ))); + + $sql = new \phpbb\event\data(array('sql_ary' => array('WHERE' => 't.forum_id = 2'))); + $listener->filter_topic_ids($sql); + $listener->filter_announcements($sql); + self::assertSame('t.forum_id = 2', $sql['sql_ary']['WHERE']); } } diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index 23c5211..0424370 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -92,9 +92,17 @@ public function test_multiple_tags_posting_display_and_filtering() self::assertSame(array($php_id), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); $this->db->sql_freeresult($result); + $crawler = self::request('GET', 'viewtopic.php?t=' . (int) $topic['topic_id'] . "&sid={$this->sid}"); + self::assertCount(1, $crawler->filter('h2.topic-title .topic-tag')); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('h2.topic-title .topic-tag')->text()); + self::assertStringContainsString('topic-tags', $crawler->filter('h2.topic-title')->children()->eq(0)->attr('class')); + $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . '&tags=' . $php_id . "&sid={$this->sid}"); self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); + + $crawler = self::request('GET', 'mcp.php?i=main&mode=forum_view&f=' . self::FORUM_ID . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); } protected function create_tag($name, $color, array $forum_ids) diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php index 9cb55ef..e3dac51 100644 --- a/tests/tags/renderer_test.php +++ b/tests/tags/renderer_test.php @@ -12,6 +12,20 @@ class renderer_test extends \phpbb_test_case { + /** + * Set up URL generation event dispatcher. + */ + protected function setUp(): void + { + parent::setUp(); + + global $phpbb_dispatcher; + $phpbb_dispatcher = new \phpbb_mock_event_dispatcher(); + } + + /** + * Test automatic foreground contrast. + */ public function test_automatic_contrast() { $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); @@ -19,4 +33,42 @@ public function test_automatic_contrast() self::assertSame('#000000', $renderer->contrast_color('FFFFFF')); self::assertSame('#FFFFFF', $renderer->contrast_color('1D70B8')); } + + /** + * Test filter URLs normalize IDs and preserve topic sorting. + */ + public function test_filter_url_preserves_sorting_and_normalizes_tags(): void + { + $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); + $url = $renderer->filter_url(2, [3, 1, 3, 0], [ + 'st' => 7, + 'sk' => 't', + 'sd' => 'd', + ]); + + self::assertStringContainsString('f=2', $url); + self::assertStringContainsString('st=7', $url); + self::assertStringContainsString('sk=t', $url); + self::assertStringContainsString('sd=d', $url); + self::assertStringContainsString('tags=1%2C3', $url); + } + + /** + * Test selected filter tag links remove that tag. + */ + public function test_rendered_filter_tags_toggle_selected_ids(): void + { + $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); + $tags = [[ + 'prefix_id' => 1, + 'prefix_tag' => 'Bug', + 'prefix_color' => 'D4351C', + ]]; + + $rendered = $renderer->render($tags, 2, [1, 2], [], true); + + self::assertTrue($rendered[0]['S_SELECTED']); + self::assertStringContainsString('tags=2', $rendered[0]['U_FILTER']); + self::assertSame('#FFFFFF', $rendered[0]['TAG_TEXT_COLOR']); + } } From e47df673af6a70e65f81929cae11993dc954d5b8 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sat, 19 Sep 2026 16:40:23 -0700 Subject: [PATCH 03/32] Add UCP listeners assuming we get these events in the core --- event/display_listener.php | 52 +++++++++++++++++++ ...> search_results_topic_title_prepend.html} | 0 tests/event/display_listener_test.php | 48 +++++++++++++++++ tests/functional/functional_test.php | 30 +++++++++++ 4 files changed, 130 insertions(+) rename styles/all/template/event/{search_results_topic_title_before.html => search_results_topic_title_prepend.html} (100%) diff --git a/event/display_listener.php b/event/display_listener.php index df63553..44fc7d6 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -35,6 +35,9 @@ class display_listener implements EventSubscriberInterface /** @var array Tags grouped by MCP topic */ protected $mcp_tags = []; + /** @var array Tags grouped by UCP topic */ + protected $ucp_tags = []; + /** * Constructor. * @@ -60,6 +63,10 @@ public static function getSubscribedEvents(): array 'core.search_modify_tpl_ary' => 'add_search_tags', 'core.mcp_forum_topic_data_modify_sql' => 'load_mcp_tags', 'core.mcp_view_forum_modify_topicrow' => 'add_mcp_tags', + 'core.ucp_main_front_modify_topic_data' => 'load_ucp_tags', + 'core.ucp_main_front_modify_template_vars' => 'add_ucp_front_tags', + 'core.ucp_main_topiclist_modify_topic_data' => 'load_ucp_tags', + 'core.ucp_main_topiclist_topic_modify_template_vars' => 'add_ucp_topiclist_tags', ]; } @@ -138,4 +145,49 @@ public function add_mcp_tags($event): void ); $event['topic_row'] = $topic_row; } + + /** + * Batch-load tags for topics displayed in a UCP topic list. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function load_ucp_tags($event): void + { + $this->ucp_tags = $this->assignments->get_tags_for_topics($event['topic_list']); + } + + /** + * Add tag badge data to one UCP front-page topic row. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_ucp_front_tags($event): void + { + $topic_id = (int) $event['row']['topic_id']; + $topic_row = $event['topicrow']; + $topic_row['TOPIC_TAGS'] = $this->renderer->render( + $this->ucp_tags[$topic_id] ?? [], + (int) $event['forum_id'] + ); + $event['topicrow'] = $topic_row; + } + + /** + * Add tag badge data to one UCP watched/bookmarked topic row. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function add_ucp_topiclist_tags($event): void + { + $topic_id = (int) $event['topic_id']; + $template_vars = $event['template_vars']; + $template_vars['TOPIC_TAGS'] = $this->renderer->render( + $this->ucp_tags[$topic_id] ?? [], + (int) $event['forum_id'] + ); + $event['template_vars'] = $template_vars; + } } diff --git a/styles/all/template/event/search_results_topic_title_before.html b/styles/all/template/event/search_results_topic_title_prepend.html similarity index 100% rename from styles/all/template/event/search_results_topic_title_before.html rename to styles/all/template/event/search_results_topic_title_prepend.html diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php index abf1ed7..e0556b0 100644 --- a/tests/event/display_listener_test.php +++ b/tests/event/display_listener_test.php @@ -23,6 +23,10 @@ public function test_subscribed_events(): void 'core.search_modify_tpl_ary', 'core.mcp_forum_topic_data_modify_sql', 'core.mcp_view_forum_modify_topicrow', + 'core.ucp_main_front_modify_topic_data', + 'core.ucp_main_front_modify_template_vars', + 'core.ucp_main_topiclist_modify_topic_data', + 'core.ucp_main_topiclist_topic_modify_template_vars', ], array_keys(\phpbb\topicprefixes\event\display_listener::getSubscribedEvents())); } @@ -182,4 +186,48 @@ public function test_mcp_tags_are_batch_loaded_and_rendered(): void self::assertSame($rendered, $event['topic_row']['MCP_TOPIC_TAGS']); } + + /** + * Test UCP tags are batch-loaded and added to both topic-row formats. + */ + public function test_ucp_tags_are_batch_loaded_and_rendered(): void + { + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer') + ->disableOriginalConstructor() + ->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->getMock(); + $tag = ['prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C']; + $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; + + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([42, 43]) + ->willReturn([42 => [1 => $tag]]); + $renderer->expects(self::exactly(2)) + ->method('render') + ->with([1 => $tag], 2) + ->willReturn($rendered); + + $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener->load_ucp_tags(new \phpbb\event\data(['topic_list' => [42, 43]])); + + $front_event = new \phpbb\event\data([ + 'row' => ['topic_id' => 42], + 'forum_id' => 2, + 'topicrow' => ['TOPIC_TITLE' => 'Tagged topic'], + ]); + $listener->add_ucp_front_tags($front_event); + self::assertSame($rendered, $front_event['topicrow']['TOPIC_TAGS']); + + $list_event = new \phpbb\event\data([ + 'topic_id' => 42, + 'forum_id' => 2, + 'template_vars' => ['TOPIC_TITLE' => 'Tagged topic'], + ]); + $listener->add_ucp_topiclist_tags($list_event); + self::assertSame($rendered, $list_event['template_vars']['TOPIC_TAGS']); + } } diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index 0424370..dc3aaf4 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -101,8 +101,38 @@ public function test_multiple_tags_posting_display_and_filtering() self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); + $crawler = self::request('GET', 'search.php?author_id=2&sr=posts' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('.postprofile .topic-tag')->text()); + $crawler = self::request('GET', 'mcp.php?i=main&mode=forum_view&f=' . self::FORUM_ID . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + + $this->db->sql_query('DELETE FROM phpbb_topics_watch + WHERE topic_id = ' . (int) $topic['topic_id'] . ' + AND user_id = 2'); + $this->db->sql_query('INSERT INTO phpbb_topics_watch ' . $this->db->sql_build_array('INSERT', array( + 'topic_id' => (int) $topic['topic_id'], + 'user_id' => 2, + 'notify_status' => 0, + ))); + $this->db->sql_query('DELETE FROM phpbb_bookmarks + WHERE topic_id = ' . (int) $topic['topic_id'] . ' + AND user_id = 2'); + $this->db->sql_query('INSERT INTO phpbb_bookmarks ' . $this->db->sql_build_array('INSERT', array( + 'topic_id' => (int) $topic['topic_id'], + 'user_id' => 2, + ))); + + $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=subscribed' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=bookmarks' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + + $this->db->sql_query('UPDATE phpbb_topics + SET topic_type = 3 + WHERE topic_id = ' . (int) $topic['topic_id']); + $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=front' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); } protected function create_tag($name, $color, array $forum_ids) From 3a78f495fd47ecbcbce4d3e6a3740dc03b824bf4 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sat, 19 Sep 2026 19:34:05 -0700 Subject: [PATCH 04/32] Ensure topic tags stay with a topic after being moved to other forums --- config/services.yml | 1 + event/posting_listener.php | 65 +++++++++++++++++++++++-- event/viewforum_listener.php | 13 ++++- tags/assignment_manager.php | 37 +++++++++++++- tests/dbal/assignment_manager_test.php | 12 +++++ tests/dbal/tags_base.php | 3 +- tests/event/posting_listener_test.php | 53 +++++++++++++++++++- tests/event/viewforum_listener_test.php | 4 +- 8 files changed, 179 insertions(+), 9 deletions(-) diff --git a/config/services.yml b/config/services.yml index 0b6c735..6906280 100644 --- a/config/services.yml +++ b/config/services.yml @@ -61,6 +61,7 @@ services: - '@dbal.conn' - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' - '%phpbb.topicprefixes.tables.topic_prefixes%' + - '%tables.topics%' phpbb.topicprefixes.tags.renderer: class: phpbb\topicprefixes\tags\renderer diff --git a/event/posting_listener.php b/event/posting_listener.php index d0c3aed..38dc01f 100644 --- a/event/posting_listener.php +++ b/event/posting_listener.php @@ -90,9 +90,13 @@ public function validate_submission($event): void $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); $submitted = $this->get_submitted_tag_ids(); - $valid = array_keys($this->manager->get_assignable_tags($event['forum_id'], $submitted)); + $valid = $this->get_valid_tag_ids( + (int) $event['forum_id'], + $submitted, + $event['mode'], + (int) ($event['topic_id'] ?? 0) + ); sort($submitted, SORT_NUMERIC); - sort($valid, SORT_NUMERIC); if ($submitted !== $valid) { $error = $event['error']; @@ -119,6 +123,15 @@ public function add_to_posting_form($event): void $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); $available = $this->manager->get_available_tags($event['forum_id']); + $assigned = []; + if ($event['mode'] === 'edit') + { + $topic_tags = $this->assignments->get_tags_for_topics([$event['topic_id']]); + $assigned = $topic_tags[(int) $event['topic_id']] ?? []; + $available += $assigned; + uasort($available, [$this, 'compare_tags']); + } + if (!$available) { return; @@ -130,7 +143,7 @@ public function add_to_posting_form($event): void } else if ($event['mode'] === 'edit') { - $selected = $this->assignments->get_topic_tag_ids($event['topic_id']); + $selected = array_keys($assigned); } else { @@ -163,7 +176,12 @@ public function capture_submission($event): void if ($this->submitted_tag_ids === null) { $submitted = $this->get_submitted_tag_ids(); - $this->submitted_tag_ids = array_keys($this->manager->get_assignable_tags($event['forum_id'], $submitted)); + $this->submitted_tag_ids = $this->get_valid_tag_ids( + (int) $event['forum_id'], + $submitted, + $event['mode'], + (int) ($event['topic_id'] ?? 0) + ); } } @@ -206,4 +224,43 @@ protected function get_submitted_tag_ids(): array $ids = $this->request->variable('topic_tags', [0]); return array_values(array_unique(array_filter(array_map('intval', $ids)))); } + + /** + * Get submitted IDs valid for a new topic or first-post edit. + * + * Existing relationships remain valid on edit even when a topic has moved to + * a forum where those tags cannot be assigned to new topics. + * + * @param int $forum_id Forum identifier + * @param array $submitted Submitted tag identifiers + * @param string $mode Posting mode + * @param int $topic_id Topic identifier + * @return array Valid tag identifiers + */ + protected function get_valid_tag_ids(int $forum_id, array $submitted, string $mode, int $topic_id): array + { + $valid = array_keys($this->manager->get_assignable_tags($forum_id, $submitted)); + if ($mode === 'edit' && $topic_id) + { + $current = $this->assignments->get_topic_tag_ids($topic_id); + $valid = array_unique(array_merge($valid, array_intersect($submitted, $current))); + } + sort($valid, SORT_NUMERIC); + + return $valid; + } + + /** + * Compare tags by administrator order then identifier. + * + * @param array $left Left tag + * @param array $right Right tag + * @return int Comparison result + */ + protected function compare_tags(array $left, array $right): int + { + $order = (int) ($left['prefix_order'] ?? 0) <=> (int) ($right['prefix_order'] ?? 0); + + return $order ?: (int) $left['prefix_id'] <=> (int) $right['prefix_id']; + } } diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index 0f3e49e..f50d288 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -106,7 +106,18 @@ public function configure_filter($event): void $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); $available = $this->manager->get_available_tags($this->forum_id); - $filterable = $this->manager->get_available_tags($this->forum_id, false); + $forum_tags = $this->manager->get_available_tags($this->forum_id, false); + $assigned = $this->assignments->get_tags_for_forum($this->forum_id); + $filterable = $forum_tags + $assigned; + + // Include tags preserved on topics moved from another forum. + foreach ($assigned as $tag_id => $tag) + { + if (!isset($forum_tags[$tag_id])) + { + $available[$tag_id] = $tag; + } + } $this->selected_ids = array_values(array_intersect($this->get_requested_ids(), array_keys($filterable))); // Keep disabled selected tags visible so users can remove active filters. diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index 2464111..09ea242 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -26,18 +26,23 @@ class assignment_manager /** @var string Tag definition table */ protected $tags_table; + /** @var string Topics table */ + protected $topics_table; + /** * Constructor. * * @param driver_interface $db Database connection * @param string $topic_map_table Topic/tag map table * @param string $tags_table Tag definition table + * @param string $topics_table Topics table */ - public function __construct(driver_interface $db, $topic_map_table, $tags_table) + public function __construct(driver_interface $db, $topic_map_table, $tags_table, $topics_table) { $this->db = $db; $this->topic_map_table = $topic_map_table; $this->tags_table = $tags_table; + $this->topics_table = $topics_table; } /** @@ -118,4 +123,34 @@ public function get_tags_for_topics(array $topic_ids): array return $tags; } + + /** + * Get tags assigned to topics currently in one forum. + * + * Includes tags that are disabled or unavailable for new assignments in the + * forum, so relationships preserved by topic moves remain filterable. + * + * @param int $forum_id Forum identifier + * @return array Tags keyed by identifier + */ + public function get_tags_for_forum(int $forum_id): array + { + $sql = 'SELECT DISTINCT p.* + FROM ' . $this->topic_map_table . ' pt + INNER JOIN ' . $this->tags_table . ' p + ON p.prefix_id = pt.prefix_id + INNER JOIN ' . $this->topics_table . ' t + ON t.topic_id = pt.topic_id + WHERE t.forum_id = ' . (int) $forum_id . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tags = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $tags[(int) $row['prefix_id']] = $row; + } + $this->db->sql_freeresult($result); + + return $tags; + } } diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index d30be27..653745d 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -41,6 +41,18 @@ public function test_batch_retrieval() self::assertArrayNotHasKey(13, $tags); } + /** + * Test moving a topic preserves its tags and exposes them for forum filters. + */ + public function test_topic_move_preserves_assignments(): void + { + $this->db->sql_query('UPDATE phpbb_topics SET forum_id = 3 WHERE topic_id = 12'); + $manager = $this->create_assignment_manager(); + + self::assertSame(array(2), $manager->get_topic_tag_ids(12)); + self::assertSame(array(2), array_keys($manager->get_tags_for_forum(3))); + } + /** * Test invalid and empty identifiers avoid relationship queries. */ diff --git a/tests/dbal/tags_base.php b/tests/dbal/tags_base.php index 2d23321..790e574 100644 --- a/tests/dbal/tags_base.php +++ b/tests/dbal/tags_base.php @@ -47,7 +47,8 @@ protected function create_assignment_manager() return new \phpbb\topicprefixes\tags\assignment_manager( $this->db, 'phpbb_topic_prefixes_topics', - 'phpbb_topic_prefixes' + 'phpbb_topic_prefixes', + 'phpbb_topics' ); } } diff --git a/tests/event/posting_listener_test.php b/tests/event/posting_listener_test.php index 3ef053d..f178f21 100644 --- a/tests/event/posting_listener_test.php +++ b/tests/event/posting_listener_test.php @@ -66,7 +66,10 @@ public function test_first_post_edit_loads_existing_assignments() { $this->manager->method('get_available_tags')->willReturn($this->tags()); $this->request->method('is_set_post')->willReturn(false); - $this->assignments->expects(self::once())->method('get_topic_tag_ids')->with(10)->willReturn(array(2)); + $this->assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with(array(10)) + ->willReturn(array(10 => array(2 => $this->tags()[2]))); $event = new \phpbb\event\data(array( 'mode' => 'edit', 'forum_id' => 2, 'topic_id' => 10, 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'page_data' => array(), @@ -76,6 +79,54 @@ public function test_first_post_edit_loads_existing_assignments() self::assertTrue($event['page_data']['TOPIC_TAGS'][1]['S_SELECTED']); } + /** + * Test first-post edits retain tags unavailable in a topic's new forum. + */ + public function test_first_post_edit_preserves_moved_topic_tags(): void + { + $tags = $this->tags(); + $this->manager->method('get_available_tags')->with(3)->willReturn(array(1 => $tags[1])); + $this->request->method('is_set_post')->willReturn(false); + $this->assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with(array(10)) + ->willReturn(array(10 => array(2 => $tags[2]))); + $event = new \phpbb\event\data(array( + 'mode' => 'edit', 'forum_id' => 3, 'topic_id' => 10, + 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'page_data' => array(), + )); + + $this->listener()->add_to_posting_form($event); + + self::assertCount(2, $event['page_data']['TOPIC_TAGS']); + self::assertTrue($event['page_data']['TOPIC_TAGS'][1]['S_SELECTED']); + } + + /** + * Test an existing moved-topic tag is valid on edit but cannot be newly added. + */ + public function test_moved_topic_tag_is_valid_only_for_existing_assignment(): void + { + $this->request->method('is_set_post')->willReturn(true); + $this->request->method('variable')->willReturn(array(2)); + $this->manager->expects(self::once()) + ->method('get_assignable_tags') + ->with(3, array(2)) + ->willReturn(array()); + $this->assignments->expects(self::once()) + ->method('get_topic_tag_ids') + ->with(10) + ->willReturn(array(2)); + $event = new \phpbb\event\data(array( + 'submit' => true, 'mode' => 'edit', 'forum_id' => 3, 'topic_id' => 10, + 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'error' => array(), + )); + + $this->listener()->validate_submission($event); + + self::assertSame(array(), $event['error']); + } + public function test_invalid_disabled_or_unavailable_ids_are_rejected() { $this->request->method('is_set_post')->willReturn(true); diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index 5c549c3..e185e32 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -44,8 +44,9 @@ public function test_filter_modifies_ids_count_and_pagination() 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8', 'prefix_order' => 2), ); $manager->method('get_available_tags')->willReturnCallback(function ($forum_id, $enabled_only = true) use ($tags) { - return $enabled_only ? array(1 => $tags[1]) : $tags; + return array(1 => $tags[1]); }); + $assignments->expects(self::once())->method('get_tags_for_forum')->with(2)->willReturn(array(2 => $tags[2])); $request->method('variable')->with('tags', '')->willReturn('1,2'); $filter->expects(self::once())->method('count_topics')->with(2, array(1, 2), 0)->willReturn(7); $filter->method('condition')->willReturn('FILTER_CONDITION'); @@ -105,6 +106,7 @@ public function test_invalid_filter_is_ignored(): void $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); $manager->method('get_available_tags')->willReturn(array()); + $assignments->method('get_tags_for_forum')->willReturn(array()); $request->method('variable')->with('tags', '')->willReturn('1,invalid'); $filter->expects(self::never())->method('count_topics'); $renderer->method('render')->willReturn(array()); From a1398e0cd37f9f438377c202e4bd39c4098528eb Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sat, 19 Sep 2026 20:21:34 -0700 Subject: [PATCH 05/32] Add orphan handling --- CHANGELOG.md | 3 + composer.json | 2 +- config/services.yml | 9 + event/display_listener.php | 9 +- event/lifecycle_listener.php | 218 ++++++++++++++++++ event/viewforum_listener.php | 12 +- ext.php | 4 +- migrations/v200_data.php | 32 +-- .../event/posting_editor_subject_before.html | 2 +- tags/assignment_manager.php | 181 ++++++++++++++- tags/filter.php | 8 +- tags/manager.php | 30 ++- tests/dbal/assignment_manager_test.php | 61 ++++- tests/dbal/filter_test.php | 11 + tests/dbal/legacy_migration_test.php | 14 ++ tests/dbal/tag_manager_test.php | 9 + tests/event/display_listener_test.php | 8 +- tests/event/lifecycle_listener_test.php | 133 +++++++++++ tests/event/viewforum_listener_test.php | 19 +- 19 files changed, 726 insertions(+), 39 deletions(-) create mode 100644 event/lifecycle_listener.php create mode 100644 tests/event/lifecycle_listener_test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 610a4d4..1c45565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ - Replaced title text prefixes with relational topic tag metadata. - Added multiple colored tags per topic and automatic badge text contrast. - Added global ACP tag management with forum availability controls. +- Raised minimum supported phpBB version to 3.3.18 for required lifecycle events. - Added accessible tag selection when creating topics and editing first posts. - Added tag badges to forum, topic, and search displays. - Added multi-tag AND filtering to forum topic lists. +- Preserved tag metadata through topic moves, splits, forks, merges, and ACP post relocation. +- Added relationship cleanup for topic and forum deletion. - Added conservative migration of legacy definitions, assignments, titles, and first-post subjects. ### 1.0.2 - 2025-12-17 diff --git a/composer.json b/composer.json index 9cd9dd6..a47e71a 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "extra": { "display-name": "phpBB Topic Prefixes", "soft-require": { - "phpbb/phpbb": ">=3.3.0" + "phpbb/phpbb": ">=3.3.18" }, "version-check": { "host": "www.phpbb.com", diff --git a/config/services.yml b/config/services.yml index 6906280..b3adc6f 100644 --- a/config/services.yml +++ b/config/services.yml @@ -35,6 +35,15 @@ services: tags: - { name: event.listener } + phpbb.topicprefixes.lifecycle_listener: + class: phpbb\topicprefixes\event\lifecycle_listener + arguments: + - '@phpbb.topicprefixes.tags.assignment_manager' + - '@phpbb.topicprefixes.tags.manager' + - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' + tags: + - { name: event.listener } + phpbb.topicprefixes.admin_controller: class: phpbb\topicprefixes\controller\admin_controller arguments: diff --git a/event/display_listener.php b/event/display_listener.php index 44fc7d6..cdb24ae 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -35,6 +35,9 @@ class display_listener implements EventSubscriberInterface /** @var array Tags grouped by MCP topic */ protected $mcp_tags = []; + /** @var array Effective tag-bearing topic IDs keyed by MCP topic ID */ + protected $mcp_tag_topic_ids = []; + /** @var array Tags grouped by UCP topic */ protected $ucp_tags = []; @@ -126,7 +129,8 @@ public function add_search_tags($event): void */ public function load_mcp_tags($event): void { - $this->mcp_tags = $this->assignments->get_tags_for_topics($event['topic_list']); + $this->mcp_tag_topic_ids = $this->assignments->get_effective_topic_ids($event['topic_list']); + $this->mcp_tags = $this->assignments->get_tags_for_topics(array_values($this->mcp_tag_topic_ids)); } /** @@ -138,9 +142,10 @@ public function load_mcp_tags($event): void public function add_mcp_tags($event): void { $topic_id = (int) $event['row']['topic_id']; + $tag_topic_id = $this->mcp_tag_topic_ids[$topic_id] ?? $topic_id; $topic_row = $event['topic_row']; $topic_row['MCP_TOPIC_TAGS'] = $this->renderer->render( - $this->mcp_tags[$topic_id] ?? [], + $this->mcp_tags[$tag_topic_id] ?? [], (int) $event['row']['forum_id'] ); $event['topic_row'] = $topic_row; diff --git a/event/lifecycle_listener.php b/event/lifecycle_listener.php new file mode 100644 index 0000000..9580b6a --- /dev/null +++ b/event/lifecycle_listener.php @@ -0,0 +1,218 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\event; + +use phpbb\topicprefixes\tags\assignment_manager; +use phpbb\topicprefixes\tags\manager; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Keep tag relationships consistent across phpBB topic lifecycle operations. + */ +class lifecycle_listener implements EventSubscriberInterface +{ + /** @var assignment_manager */ + protected $assignments; + + /** @var manager */ + protected $manager; + + /** @var string */ + protected $topic_map_table; + + /** @var array Tag IDs captured immediately before topic deletion */ + protected $deleted_topic_tags = []; + + /** @var array Forked topic IDs already processed */ + protected $processed_forks = []; + + /** + * Constructor. + * + * @param assignment_manager $assignments Topic/tag assignment manager + * @param manager $manager Tag manager + * @param string $topic_map_table Topic/tag map table + */ + public function __construct(assignment_manager $assignments, manager $manager, $topic_map_table) + { + $this->assignments = $assignments; + $this->manager = $manager; + $this->topic_map_table = $topic_map_table; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + 'core.delete_topics_before_query' => 'delete_topic_relationships', + 'core.delete_forum_content_before_query' => 'delete_forum_topic_relationships', + 'core.acp_manage_forums_move_content_after' => 'delete_moved_forum_availability', + 'core.mcp_topic_split_topic_after' => 'copy_split_tags', + 'core.mcp_main_fork_sql_after' => 'copy_fork_tags', + 'core.mcp_forum_merge_topics_after' => 'merge_topics_tags', + 'core.mcp_topics_merge_posts_after' => 'merge_posts_tags', + 'core.acp_users_move_posts_after' => 'copy_relocated_post_tags', + ]; + } + + /** + * Include topic/tag rows in phpBB's normal topic deletion transaction. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function delete_topic_relationships($event): void + { + $topic_ids = array_map('intval', $event['topic_ids']); + foreach ($this->assignments->get_topic_tag_ids_for_topics($topic_ids) as $topic_id => $tag_ids) + { + $this->deleted_topic_tags[(int) $topic_id] = $tag_ids; + } + + $tables = $event['table_ary']; + if (!in_array($this->topic_map_table, $tables, true)) + { + $tables[] = $this->topic_map_table; + } + $event['table_ary'] = $tables; + } + + /** + * Remove assignments before phpBB directly deletes every topic in a forum. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function delete_forum_topic_relationships($event): void + { + $forum_id = (int) $event['forum_id']; + $this->assignments->delete_forum_topic_assignments($forum_id); + $this->manager->delete_forum_availability([$forum_id]); + } + + /** + * Remove availability after all content leaves a postable forum. + * + * phpBB uses this operation when deleting a forum while moving its content, + * and when converting a postable forum to a non-postable forum. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function delete_moved_forum_availability($event): void + { + $this->manager->delete_forum_availability([(int) $event['from_id']]); + } + + /** + * Copy source-topic tags to a newly split topic. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function copy_split_tags($event): void + { + $source_topic_id = (int) $event['topic_id']; + if (isset($this->deleted_topic_tags[$source_topic_id])) + { + $this->assignments->set_topic_tags((int) $event['to_topic_id'], $this->deleted_topic_tags[$source_topic_id]); + unset($this->deleted_topic_tags[$source_topic_id]); + return; + } + + $this->assignments->copy_topic_tags($source_topic_id, (int) $event['to_topic_id']); + } + + /** + * Copy source-topic tags to a newly forked topic once per fork. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function copy_fork_tags($event): void + { + $new_topic_id = (int) $event['new_topic_id']; + if (isset($this->processed_forks[$new_topic_id])) + { + return; + } + + $this->processed_forks[$new_topic_id] = true; + $this->assignments->copy_topic_tags((int) $event['row']['topic_id'], $new_topic_id); + } + + /** + * Preserve all source tags when complete topics are merged. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function merge_topics_tags($event): void + { + $target_topic_id = (int) $event['to_topic_id']; + $source_topic_ids = array_diff(array_map('intval', array_keys($event['all_topic_data'])), [$target_topic_id]); + $this->merge_deleted_tags($source_topic_ids, $target_topic_id); + } + + /** + * Preserve source tags when merging posts removes the source topic. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function merge_posts_tags($event): void + { + $source_topic_id = (int) $event['topic_id']; + if (!$this->assignments->topic_exists($source_topic_id)) + { + $this->merge_deleted_tags([$source_topic_id], (int) $event['to_topic_id']); + } + } + + /** + * Copy tags when ACP user administration creates topics from relocated posts. + * + * @param \phpbb\event\data $event Event data + * @return void + */ + public function copy_relocated_post_tags($event): void + { + foreach ($event['new_topic_id_map'] as $source_topic_id => $new_topic_id) + { + $this->assignments->copy_topic_tags((int) $source_topic_id, (int) $new_topic_id); + } + } + + /** + * Add captured source tags to a merge destination. + * + * @param array $source_topic_ids Source topic identifiers + * @param int $target_topic_id Destination topic identifier + * @return void + */ + protected function merge_deleted_tags(array $source_topic_ids, int $target_topic_id): void + { + $tag_ids = []; + foreach ($source_topic_ids as $source_topic_id) + { + $source_topic_id = (int) $source_topic_id; + $tag_ids = array_merge($tag_ids, $this->deleted_topic_tags[$source_topic_id] ?? $this->assignments->get_topic_tag_ids($source_topic_id)); + unset($this->deleted_topic_tags[$source_topic_id]); + } + + if ($tag_ids) + { + $this->assignments->add_topic_tags($target_topic_id, $tag_ids); + } + } +} diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index f50d288..0c14a15 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -57,6 +57,9 @@ class viewforum_listener implements EventSubscriberInterface /** @var array Tags grouped by topic identifier */ protected $topic_tags = []; + /** @var array Effective tag-bearing topic IDs keyed by displayed topic ID */ + protected $tag_topic_ids = []; + /** * Constructor. * @@ -195,9 +198,13 @@ public function filter_topic_ids($event): void public function load_topic_tags($event): void { $topic_ids = []; + $this->tag_topic_ids = []; foreach ($event['rowset'] as $row) { - $topic_ids[] = (int) $row['topic_id']; + $topic_id = (int) $row['topic_id']; + $tag_topic_id = !empty($row['topic_moved_id']) ? (int) $row['topic_moved_id'] : $topic_id; + $this->tag_topic_ids[$topic_id] = $tag_topic_id; + $topic_ids[] = $tag_topic_id; } $this->topic_tags = $this->assignments->get_tags_for_topics($topic_ids); } @@ -211,7 +218,8 @@ public function load_topic_tags($event): void public function add_topic_tags($event): void { $topic_id = (int) $event['row']['topic_id']; - $tags = $this->topic_tags[$topic_id] ?? []; + $tag_topic_id = $this->tag_topic_ids[$topic_id] ?? $topic_id; + $tags = $this->topic_tags[$tag_topic_id] ?? []; $topic_row = $event['topic_row']; $topic_row['TOPIC_TAGS'] = $this->renderer->render($tags, $this->forum_id, $this->selected_ids, $this->sort_params); $event['topic_row'] = $topic_row; diff --git a/ext.php b/ext.php index f4f3a67..f617c39 100644 --- a/ext.php +++ b/ext.php @@ -25,12 +25,12 @@ class ext extends \phpbb\extension\base * The current phpBB version should meet or exceed * the minimum version required by this extension: * - * Requires phpBB 3.3.0 and PHP 7.1 + * Requires phpBB 3.3.18 and PHP 7.1 * * @return bool */ public function is_enableable() { - return phpbb_version_compare(PHPBB_VERSION, '3.3.0', '>=') && PHP_VERSION_ID >= 70100; + return phpbb_version_compare(PHPBB_VERSION, '3.3.18-dev', '>=') && PHP_VERSION_ID >= 70100; } } diff --git a/migrations/v200_data.php b/migrations/v200_data.php index 5aa5884..ca37511 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -17,6 +17,7 @@ class v200_data extends \phpbb\db\migration\migration { const DEFAULT_COLOR = '4A76A8'; const BATCH_SIZE = 500; + const UPDATE_CASE_BATCH_SIZE = 10; /** * {@inheritdoc} @@ -148,7 +149,7 @@ protected function migrate_forum_availability(string $tags_table, string $forums */ protected function get_legacy_topics(array $tables, int $last_topic_id): array { - $sql = 'SELECT t.topic_id, t.topic_title, t.topic_first_post_id, + $sql = 'SELECT t.topic_id, t.topic_title, t.topic_first_post_id, t.topic_moved_id, t.topic_prefix_id, p.prefix_tag, fp.post_subject FROM ' . $tables['topics'] . ' t INNER JOIN ' . $tables['tags'] . ' p @@ -188,7 +189,7 @@ protected function migrate_topic_batch(array $tables, array $topics): void { $topic_id = (int) $topic['topic_id']; $tag_id = (int) $topic['topic_prefix_id']; - if (empty($existing[$topic_id][$tag_id])) + if (empty($topic['topic_moved_id']) && empty($existing[$topic_id][$tag_id])) { $assignments[] = ['topic_id' => $topic_id, 'prefix_id' => $tag_id]; } @@ -245,7 +246,7 @@ protected function get_existing_assignments(string $topic_tags_table, array $top } /** - * Update distinct text values with one CASE query. + * Update distinct text values with portable, bounded conditional queries. * * @param string $table Table name * @param string $id_column Integer primary key column @@ -260,17 +261,22 @@ protected function bulk_update_text(string $table, string $id_column, string $va return; } - $cases = []; - foreach ($changes as $id => $value) + foreach (array_chunk($changes, self::UPDATE_CASE_BATCH_SIZE, true) as $batch) { - $cases[] = 'WHEN ' . (int) $id . " THEN '" . $this->db->sql_escape($value) . "'"; - } + $value_sql = $value_column; + foreach (array_reverse($batch, true) as $id => $value) + { + $value_sql = $this->db->sql_case( + $id_column . ' = ' . (int) $id, + "'" . $this->db->sql_escape($value) . "'", + $value_sql + ); + } - $sql = 'UPDATE ' . $table . ' - SET ' . $value_column . ' = CASE ' . $id_column . ' ' . implode(' ', $cases) . ' - ELSE ' . $value_column . ' - END - WHERE ' . $this->db->sql_in_set($id_column, array_keys($changes)); - $this->db->sql_query($sql); + $sql = 'UPDATE ' . $table . ' + SET ' . $value_column . ' = ' . $value_sql . ' + WHERE ' . $this->db->sql_in_set($id_column, array_keys($batch)); + $this->db->sql_query($sql); + } } } diff --git a/styles/all/template/event/posting_editor_subject_before.html b/styles/all/template/event/posting_editor_subject_before.html index 9dbe53b..68ec2e9 100644 --- a/styles/all/template/event/posting_editor_subject_before.html +++ b/styles/all/template/event/posting_editor_subject_before.html @@ -1,6 +1,6 @@ {% if S_TOPIC_TAGS and not S_PRIVMSGS %}
-
{{ lang('TOPIC_TAGS') ~ lang('COLON') }}
+
{{ lang('TOPIC_TAGS') }} diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index 09ea242..578f65f 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -52,13 +52,13 @@ public function __construct(driver_interface $db, $topic_map_table, $tags_table, * * @param int $topic_id Topic identifier * @param array $tag_ids Tag identifiers - * @return bool Whether topic identifier was valid + * @return bool Whether topic and tag identifiers were valid */ public function set_topic_tags(int $topic_id, array $tag_ids): bool { $topic_id = (int) $topic_id; $tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids)))); - if (!$topic_id) + if (!$topic_id || !$this->topic_exists($topic_id) || !$this->tags_exist($tag_ids)) { return false; } @@ -79,6 +79,68 @@ public function set_topic_tags(int $topic_id, array $tag_ids): bool return true; } + /** + * Add tags without removing existing assignments. + * + * @param int $topic_id Topic identifier + * @param array $tag_ids Tag identifiers + * @return bool Whether topic and tag identifiers were valid + */ + public function add_topic_tags(int $topic_id, array $tag_ids): bool + { + return $this->set_topic_tags($topic_id, array_merge($this->get_topic_tag_ids($topic_id), $tag_ids)); + } + + /** + * Copy every assignment from one topic to another. + * + * @param int $source_topic_id Source topic identifier + * @param int $target_topic_id Target topic identifier + * @return bool Whether both topics were valid + */ + public function copy_topic_tags(int $source_topic_id, int $target_topic_id): bool + { + if (!$this->topic_exists($source_topic_id)) + { + return false; + } + + return $this->set_topic_tags($target_topic_id, $this->get_topic_tag_ids($source_topic_id)); + } + + /** + * Delete assignments belonging to topics. + * + * @param array $topic_ids Topic identifiers + * @return void + */ + public function delete_topic_assignments(array $topic_ids): void + { + $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + if ($topic_ids) + { + $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' + WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids)); + } + } + + /** + * Delete assignments for every topic currently in one forum. + * + * @param int $forum_id Forum identifier + * @return void + */ + public function delete_forum_topic_assignments(int $forum_id): void + { + $sql = 'DELETE FROM ' . $this->topic_map_table . ' + WHERE topic_id IN ( + SELECT topic_id + FROM ' . $this->topics_table . ' + WHERE forum_id = ' . (int) $forum_id . ' + )'; + $this->db->sql_query($sql); + } + /** * Get assigned tag identifiers for one topic. * @@ -91,6 +153,35 @@ public function get_topic_tag_ids(int $topic_id): array return isset($tags[$topic_id]) ? array_keys($tags[$topic_id]) : []; } + /** + * Batch-load relationship identifiers without loading tag definitions. + * + * @param array $topic_ids Topic identifiers + * @return array Tag identifiers grouped by topic identifier + */ + public function get_topic_tag_ids_for_topics(array $topic_ids): array + { + $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + if (!$topic_ids) + { + return []; + } + + $sql = 'SELECT topic_id, prefix_id + FROM ' . $this->topic_map_table . ' + WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids) . ' + ORDER BY topic_id ASC, prefix_id ASC'; + $result = $this->db->sql_query($sql); + $tag_ids = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $tag_ids[(int) $row['topic_id']][] = (int) $row['prefix_id']; + } + $this->db->sql_freeresult($result); + + return $tag_ids; + } + /** * Batch-load tag definitions for topics. * @@ -125,23 +216,58 @@ public function get_tags_for_topics(array $topic_ids): array } /** - * Get tags assigned to topics currently in one forum. + * Resolve shadow topic identifiers to their destination topics. + * + * @param array $topic_ids Topic or shadow-topic identifiers + * @return array Effective topic identifiers keyed by requested identifier + */ + public function get_effective_topic_ids(array $topic_ids): array + { + $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + if (!$topic_ids) + { + return []; + } + + $sql = 'SELECT topic_id, topic_moved_id + FROM ' . $this->topics_table . ' + WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids); + $result = $this->db->sql_query($sql); + $resolved = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $topic_id = (int) $row['topic_id']; + $resolved[$topic_id] = !empty($row['topic_moved_id']) ? (int) $row['topic_moved_id'] : $topic_id; + } + $this->db->sql_freeresult($result); + + return $resolved; + } + + /** + * Get tags assigned to topics displayed in one forum. * * Includes tags that are disabled or unavailable for new assignments in the - * forum, so relationships preserved by topic moves remain filterable. + * forum, tags reached through move shadows, and tags on global announcements. * * @param int $forum_id Forum identifier * @return array Tags keyed by identifier */ public function get_tags_for_forum(int $forum_id): array { + $effective_topic_id = $this->db->sql_case( + 't.topic_moved_id <> 0', + 't.topic_moved_id', + 't.topic_id' + ); $sql = 'SELECT DISTINCT p.* FROM ' . $this->topic_map_table . ' pt + INNER JOIN ' . $this->topics_table . ' t + ON pt.topic_id = ' . $effective_topic_id . ' INNER JOIN ' . $this->tags_table . ' p ON p.prefix_id = pt.prefix_id - INNER JOIN ' . $this->topics_table . ' t - ON t.topic_id = pt.topic_id - WHERE t.forum_id = ' . (int) $forum_id . ' + WHERE (t.forum_id = ' . (int) $forum_id . ' + OR t.topic_type = ' . POST_GLOBAL . ') ORDER BY p.prefix_order ASC, p.prefix_id ASC'; $result = $this->db->sql_query($sql); $tags = []; @@ -153,4 +279,45 @@ public function get_tags_for_forum(int $forum_id): array return $tags; } + + /** + * Check whether a topic exists. + * + * @param int $topic_id Topic identifier + * @return bool + */ + public function topic_exists(int $topic_id): bool + { + $sql = 'SELECT topic_id + FROM ' . $this->topics_table . ' + WHERE topic_id = ' . (int) $topic_id; + $result = $this->db->sql_query($sql); + $exists = $this->db->sql_fetchfield('topic_id') !== false; + $this->db->sql_freeresult($result); + + return $exists; + } + + /** + * Check whether every tag identifier exists. + * + * @param array $tag_ids Tag identifiers + * @return bool + */ + protected function tags_exist(array $tag_ids): bool + { + if (!$tag_ids) + { + return true; + } + + $sql = 'SELECT COUNT(prefix_id) AS total + FROM ' . $this->tags_table . ' + WHERE ' . $this->db->sql_in_set('prefix_id', $tag_ids); + $result = $this->db->sql_query($sql); + $total = (int) $this->db->sql_fetchfield('total'); + $this->db->sql_freeresult($result); + + return $total === count($tag_ids); + } } diff --git a/tags/filter.php b/tags/filter.php index d2e45c0..2588502 100644 --- a/tags/filter.php +++ b/tags/filter.php @@ -60,7 +60,13 @@ public function condition(string $topic_alias, array $tag_ids): string return '1=1'; } - return $topic_alias . '.topic_id IN ( + $topic_id = $this->db->sql_case( + $topic_alias . '.topic_moved_id <> 0', + $topic_alias . '.topic_moved_id', + $topic_alias . '.topic_id' + ); + + return $topic_id . ' IN ( SELECT tpf.topic_id FROM ' . $this->topic_map_table . ' tpf WHERE ' . $this->db->sql_in_set('tpf.prefix_id', $tag_ids) . ' diff --git a/tags/manager.php b/tags/manager.php index ff366f1..fb36eae 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -278,6 +278,22 @@ public function delete_tag(int $tag_id): bool return true; } + /** + * Delete availability relationships for removed forums. + * + * @param array $forum_ids Forum identifiers + * @return void + */ + public function delete_forum_availability(array $forum_ids): void + { + $forum_ids = array_values(array_unique(array_filter(array_map('intval', $forum_ids)))); + if ($forum_ids) + { + $this->db->sql_query('DELETE FROM ' . $this->forums_map_table . ' + WHERE ' . $this->db->sql_in_set('forum_id', $forum_ids)); + } + } + /** * Move tag one position in display order. * @@ -313,13 +329,19 @@ public function move_tag(int $tag_id, string $direction): bool $current_order = (int) $tags[$current]['prefix_order']; $target_id = (int) $tags[$target]['prefix_id']; $target_order = (int) $tags[$target]['prefix_order']; + $order = $this->db->sql_case( + 'prefix_id = ' . $current_id, + (string) $target_order, + $this->db->sql_case( + 'prefix_id = ' . $target_id, + (string) $current_order, + 'prefix_order' + ) + ); $this->db->sql_transaction('begin'); $sql = 'UPDATE ' . $this->tags_table . ' - SET prefix_order = CASE prefix_id - WHEN ' . $current_id . ' THEN ' . $target_order . ' - WHEN ' . $target_id . ' THEN ' . $current_order . ' - END + SET prefix_order = ' . $order . ' WHERE ' . $this->db->sql_in_set('prefix_id', [$current_id, $target_id]); $this->db->sql_query($sql); $this->db->sql_transaction('commit'); diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 653745d..50a3a66 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -35,10 +35,12 @@ public function test_assign_multiple_edit_remove_and_deduplicate() */ public function test_batch_retrieval() { - $tags = $this->create_assignment_manager()->get_tags_for_topics(array(10, 11, 13)); + $manager = $this->create_assignment_manager(); + $tags = $manager->get_tags_for_topics(array(10, 11, 13)); self::assertSame(array(1, 2), array_keys($tags[10])); self::assertSame(array(1), array_keys($tags[11])); self::assertArrayNotHasKey(13, $tags); + self::assertSame([10 => [1, 2], 11 => [1]], $manager->get_topic_tag_ids_for_topics([10, 11, 13])); } /** @@ -61,7 +63,64 @@ public function test_empty_identifiers_are_rejected(): void $manager = $this->create_assignment_manager(); self::assertFalse($manager->set_topic_tags(0, [1])); + self::assertFalse($manager->set_topic_tags(999, [1])); + self::assertTrue($manager->set_topic_tags(13, [1])); + self::assertFalse($manager->set_topic_tags(13, [999])); + self::assertSame([1], $manager->get_topic_tag_ids(13)); self::assertSame([], $manager->get_tags_for_topics([0, 0])); + self::assertSame([], $manager->get_topic_tag_ids_for_topics([0, 0])); + self::assertSame([], $manager->get_effective_topic_ids([0, 0])); self::assertSame([], $manager->get_topic_tag_ids(999)); } + + /** + * Test copying and merging assignments between topics. + */ + public function test_copy_and_add_topic_tags(): void + { + $manager = $this->create_assignment_manager(); + + self::assertTrue($manager->copy_topic_tags(10, 13)); + self::assertSame([1, 2], $manager->get_topic_tag_ids(13)); + self::assertTrue($manager->add_topic_tags(11, [2])); + self::assertSame([1, 2], $manager->get_topic_tag_ids(11)); + self::assertFalse($manager->copy_topic_tags(999, 13)); + } + + /** + * Test deletion helpers remove only relationships in scope. + */ + public function test_delete_assignment_helpers(): void + { + $manager = $this->create_assignment_manager(); + $manager->delete_topic_assignments([10]); + self::assertSame([], $manager->get_topic_tag_ids(10)); + self::assertSame([1], $manager->get_topic_tag_ids(11)); + + $manager->delete_forum_topic_assignments(2); + self::assertSame([], $manager->get_topic_tag_ids(11)); + self::assertSame([], $manager->get_topic_tag_ids(12)); + } + + /** + * Test shadow topics resolve to destination assignments. + */ + public function test_effective_topic_ids_resolve_shadows(): void + { + $this->db->sql_query('UPDATE phpbb_topics SET forum_id = 3, topic_moved_id = 10 WHERE topic_id = 13'); + $manager = $this->create_assignment_manager(); + + self::assertSame([10 => 10, 13 => 10], $manager->get_effective_topic_ids([10, 13])); + self::assertSame([1, 2], array_keys($manager->get_tags_for_forum(3))); + } + + /** + * Test global-topic tags are filterable in every forum. + */ + public function test_global_topic_tags_are_available_to_forum_filters(): void + { + $this->db->sql_query('UPDATE phpbb_topics SET forum_id = 0, topic_type = ' . POST_GLOBAL . ' WHERE topic_id = 10'); + + self::assertSame([1, 2], array_keys($this->create_assignment_manager()->get_tags_for_forum(3))); + } } diff --git a/tests/dbal/filter_test.php b/tests/dbal/filter_test.php index 2c3554b..0ecdbbf 100644 --- a/tests/dbal/filter_test.php +++ b/tests/dbal/filter_test.php @@ -62,6 +62,17 @@ public function test_empty_filter_and_sort_days(): void self::assertSame(2, $filter->count_topics(2, [1], 7)); } + /** + * Test shadow topics filter using destination-topic assignments. + */ + public function test_shadow_topic_filter_uses_destination_tags(): void + { + $this->db->sql_query('UPDATE phpbb_topics SET topic_moved_id = 10 WHERE topic_id = 13'); + + self::assertSame([10, 11, 13], $this->query_ids([1])); + self::assertSame(3, $this->create_filter()->count_topics(2, [1])); + } + /** * Query topic identifiers matching tags. * diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php index e7593a2..4618bae 100644 --- a/tests/dbal/legacy_migration_test.php +++ b/tests/dbal/legacy_migration_test.php @@ -35,12 +35,23 @@ protected function setUp(): void $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1, topic_first_post_id = 101 WHERE topic_id = 11"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2, topic_first_post_id = 102 WHERE topic_id = 12"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0, topic_first_post_id = 103 WHERE topic_id = 13"); + $this->db->sql_query('INSERT INTO phpbb_topics ' . $this->db->sql_build_array('INSERT', array( + 'topic_id' => 14, + 'forum_id' => 2, + 'topic_title' => 'Bug Moved topic', + 'topic_prefix_id' => 1, + 'topic_first_post_id' => 105, + 'topic_moved_id' => 10, + 'topic_visibility' => ITEM_APPROVED, + 'topic_type' => POST_NORMAL, + ))); foreach (array( array(100, 10, 'Bug Both tags'), array(101, 11, 'Unrelated first post'), array(102, 12, 'PHP 8.4 PHP only'), array(103, 13, '[Random] No tags'), array(104, 10, 'Bug reply subject'), + array(105, 14, 'Bug Moved topic'), ) as $post) { $sql = 'INSERT INTO phpbb_posts ' . $this->db->sql_build_array('INSERT', array( @@ -80,6 +91,9 @@ public function test_legacy_definitions_titles_subjects_and_idempotency() self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 101', 'post_subject')); self::assertSame('[Random] No tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 13', 'topic_title')); self::assertSame('Bug reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 104', 'post_subject')); + self::assertSame('Moved topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 14', 'topic_title')); + self::assertSame('Moved topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 105', 'post_subject')); + self::assertSame(0, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = 14', 'total')); self::assertSame(1, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_forums WHERE forum_id = 2 AND prefix_id = 4', 'total')); $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = 10 AND prefix_id = 1'); diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index 4bd438d..8a24c96 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -59,6 +59,15 @@ public function test_available_tags_exclude_disabled_and_wrong_forum() self::assertSame(array(1, 4), array_keys($this->create_tag_manager()->get_available_tags(3))); } + public function test_deleted_forum_availability_is_removed() + { + $manager = $this->create_tag_manager(); + $manager->delete_forum_availability([2, 999]); + + self::assertSame([], $manager->get_available_tags(2, false)); + self::assertSame([1, 4], array_keys($manager->get_available_tags(3, false))); + } + public function test_assignable_tags_reject_disabled_and_unavailable() { $tags = $this->create_tag_manager()->get_assignable_tags(2, array(1, 3, 4, 999)); diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php index e0556b0..37ef5bf 100644 --- a/tests/event/display_listener_test.php +++ b/tests/event/display_listener_test.php @@ -168,9 +168,13 @@ public function test_mcp_tags_are_batch_loaded_and_rendered(): void $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; $assignments->expects(self::once()) - ->method('get_tags_for_topics') + ->method('get_effective_topic_ids') ->with([42, 43]) - ->willReturn([42 => [1 => $tag]]); + ->willReturn([42 => 99, 43 => 43]); + $assignments->expects(self::once()) + ->method('get_tags_for_topics') + ->with([99, 43]) + ->willReturn([99 => [1 => $tag]]); $renderer->expects(self::once()) ->method('render') ->with([1 => $tag], 2) diff --git a/tests/event/lifecycle_listener_test.php b/tests/event/lifecycle_listener_test.php new file mode 100644 index 0000000..fdae5b3 --- /dev/null +++ b/tests/event/lifecycle_listener_test.php @@ -0,0 +1,133 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\topicprefixes\tests\event; + +class lifecycle_listener_test extends \phpbb_test_case +{ + public function test_subscribed_events(): void + { + self::assertSame([ + 'core.delete_topics_before_query', + 'core.delete_forum_content_before_query', + 'core.acp_manage_forums_move_content_after', + 'core.mcp_topic_split_topic_after', + 'core.mcp_main_fork_sql_after', + 'core.mcp_forum_merge_topics_after', + 'core.mcp_topics_merge_posts_after', + 'core.acp_users_move_posts_after', + ], array_keys(\phpbb\topicprefixes\event\lifecycle_listener::getSubscribedEvents())); + } + + public function test_topic_and_forum_deletion_cleanup(): void + { + $assignments = $this->assignment_mock(); + $manager = $this->manager_mock(); + $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([]); + $assignments->expects(self::once())->method('delete_forum_topic_assignments')->with(2); + $manager->expects(self::exactly(2))->method('delete_forum_availability')->withConsecutive([[2]], [[3]]); + $listener = $this->listener($assignments, $manager); + + $delete = new \phpbb\event\data(['topic_ids' => [10], 'table_ary' => ['phpbb_topics']]); + $listener->delete_topic_relationships($delete); + self::assertSame(['phpbb_topics', 'phpbb_topic_prefixes_topics'], $delete['table_ary']); + + $listener->delete_forum_topic_relationships(new \phpbb\event\data(['forum_id' => 2])); + $listener->delete_moved_forum_availability(new \phpbb\event\data(['from_id' => 3])); + } + + public function test_split_fork_and_relocated_topics_copy_tags(): void + { + $assignments = $this->assignment_mock(); + $assignments->expects(self::exactly(4))->method('copy_topic_tags')->withConsecutive( + [10, 20], + [10, 30], + [10, 40], + [11, 41] + )->willReturn(true); + $listener = $this->listener($assignments, $this->manager_mock()); + + $listener->copy_split_tags(new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20])); + $fork = new \phpbb\event\data(['new_topic_id' => 30, 'row' => ['topic_id' => 10]]); + $listener->copy_fork_tags($fork); + $listener->copy_fork_tags($fork); + $listener->copy_relocated_post_tags(new \phpbb\event\data([ + 'new_topic_id_map' => [10 => 40, 11 => 41], + ])); + } + + public function test_complete_topic_merge_unions_deleted_source_tags(): void + { + $assignments = $this->assignment_mock(); + $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); + $assignments->expects(self::once())->method('add_topic_tags')->with(20, [2])->willReturn(true); + $listener = $this->listener($assignments, $this->manager_mock()); + + $listener->delete_topic_relationships(new \phpbb\event\data([ + 'topic_ids' => [10], + 'table_ary' => ['phpbb_topics'], + ])); + $listener->merge_topics_tags(new \phpbb\event\data([ + 'to_topic_id' => 20, + 'all_topic_data' => [10 => [], 20 => []], + ])); + } + + public function test_split_all_uses_tags_captured_before_source_deletion(): void + { + $assignments = $this->assignment_mock(); + $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); + $assignments->expects(self::once())->method('set_topic_tags')->with(20, [2])->willReturn(true); + $assignments->expects(self::never())->method('copy_topic_tags'); + $listener = $this->listener($assignments, $this->manager_mock()); + + $listener->delete_topic_relationships(new \phpbb\event\data([ + 'topic_ids' => [10], + 'table_ary' => ['phpbb_topics'], + ])); + $listener->copy_split_tags(new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20])); + } + + public function test_post_merge_only_unions_tags_when_source_was_removed(): void + { + $assignments = $this->assignment_mock(); + $assignments->expects(self::exactly(2))->method('topic_exists')->with(10)->willReturnOnConsecutiveCalls(true, false); + $assignments->expects(self::once())->method('get_topic_tag_ids')->with(10)->willReturn([1]); + $assignments->expects(self::once())->method('add_topic_tags')->with(20, [1])->willReturn(true); + $listener = $this->listener($assignments, $this->manager_mock()); + $event = new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20]); + + $listener->merge_posts_tags($event); + $listener->merge_posts_tags($event); + } + + protected function listener($assignments, $manager): \phpbb\topicprefixes\event\lifecycle_listener + { + return new \phpbb\topicprefixes\event\lifecycle_listener( + $assignments, + $manager, + 'phpbb_topic_prefixes_topics' + ); + } + + protected function assignment_mock() + { + return $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function manager_mock() + { + return $this->getMockBuilder('\phpbb\topicprefixes\tags\manager') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index e185e32..a512d6d 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -69,10 +69,13 @@ public function test_filter_modifies_ids_count_and_pagination() $listener->filter_announcements($announcements); self::assertSame('(t.topic_type = 3) AND FILTER_CONDITION', $announcements['sql_ary']['WHERE']); - $assignments->expects(self::once()) + $assignments->expects(self::exactly(2)) ->method('get_tags_for_topics') - ->with(array(10, 11)) - ->willReturn(array(10 => array(1 => $tags[1]))); + ->withConsecutive([array(10, 11)], [array(20)]) + ->willReturnOnConsecutiveCalls( + array(10 => array(1 => $tags[1])), + array(20 => array(2 => $tags[2])) + ); $listener->load_topic_tags(new \phpbb\event\data(array( 'rowset' => array(array('topic_id' => 10), array('topic_id' => 11)), ))); @@ -83,6 +86,16 @@ public function test_filter_modifies_ids_count_and_pagination() $listener->add_topic_tags($row); self::assertArrayHasKey('TOPIC_TAGS', $row['topic_row']); + $listener->load_topic_tags(new \phpbb\event\data(array( + 'rowset' => array(array('topic_id' => 12, 'topic_moved_id' => 20)), + ))); + $shadow = new \phpbb\event\data(array( + 'row' => array('topic_id' => 12, 'topic_moved_id' => 20), + 'topic_row' => array('TOPIC_TITLE' => 'Moved topic'), + )); + $listener->add_topic_tags($shadow); + self::assertArrayHasKey('TOPIC_TAGS', $shadow['topic_row']); + $page = new \phpbb\event\data(array('base_url' => './viewforum.php?f=2', 'on_page' => 2, 'start_name' => 'start', 'per_page' => 25, 'generate_page_link_override' => false)); $listener->preserve_pagination_filter($page); self::assertSame('./viewforum.php?f=2&tags=1,2', $page['base_url']); From 6f716efcdc63193d10ff4b40f046f914815d7f3d Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 06:34:51 -0700 Subject: [PATCH 06/32] Fix CSS issues --- styles/all/theme/topic_tags.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css index 45a899b..3c81bf2 100644 --- a/styles/all/theme/topic_tags.css +++ b/styles/all/theme/topic_tags.css @@ -17,7 +17,9 @@ } a.topic-tag:hover, -a.topic-tag:focus { +a.topic-tag:focus, +.topic-tag-choices .topic-tag:hover, +.topic-tag-checkbox:focus + .topic-tag { filter: brightness(0.9); text-decoration: none; } @@ -35,6 +37,10 @@ a.topic-tag:focus { padding: 0; } +.topic-tag-choices .topic-tag { + cursor: pointer; +} + .topic-tag-checkbox { clip: rect(0 0 0 0); clip-path: inset(50%); From 393ffe0f2203fcbf400e52d90b1791dd75de91cb Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 06:52:57 -0700 Subject: [PATCH 07/32] Fix tests --- tests/dbal/legacy_migration_test.php | 59 ++++++++++++++++++---------- tests/functional/functional_test.php | 7 +++- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php index 4618bae..8b60aee 100644 --- a/tests/dbal/legacy_migration_test.php +++ b/tests/dbal/legacy_migration_test.php @@ -17,6 +17,12 @@ class legacy_migration_test extends tags_base /** @var \phpbb\db\tools\tools_interface */ protected $tools; + /** @var int */ + protected $moved_topic_id; + + /** @var array */ + protected $post_ids = []; + protected function setUp(): void { parent::setUp(); @@ -31,37 +37,50 @@ protected function setUp(): void $this->db->sql_query("UPDATE phpbb_topic_prefixes SET prefix_left_id = prefix_id * 2 - 1, prefix_right_id = prefix_id * 2, forum_id = 2"); $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_forums WHERE prefix_id = 4'); - $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'Bug Both tags', topic_prefix_id = 1, topic_first_post_id = 100 WHERE topic_id = 10"); - $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1, topic_first_post_id = 101 WHERE topic_id = 11"); - $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2, topic_first_post_id = 102 WHERE topic_id = 12"); - $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0, topic_first_post_id = 103 WHERE topic_id = 13"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'Bug Both tags', topic_prefix_id = 1 WHERE topic_id = 10"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1 WHERE topic_id = 11"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2 WHERE topic_id = 12"); + $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0 WHERE topic_id = 13"); $this->db->sql_query('INSERT INTO phpbb_topics ' . $this->db->sql_build_array('INSERT', array( - 'topic_id' => 14, 'forum_id' => 2, 'topic_title' => 'Bug Moved topic', 'topic_prefix_id' => 1, - 'topic_first_post_id' => 105, 'topic_moved_id' => 10, 'topic_visibility' => ITEM_APPROVED, 'topic_type' => POST_NORMAL, ))); + $this->moved_topic_id = (int) $this->db->sql_nextid(); + foreach (array( - array(100, 10, 'Bug Both tags'), - array(101, 11, 'Unrelated first post'), - array(102, 12, 'PHP 8.4 PHP only'), - array(103, 13, '[Random] No tags'), - array(104, 10, 'Bug reply subject'), - array(105, 14, 'Bug Moved topic'), + array('both', 10, 'Bug Both tags'), + array('unrelated', 11, 'Unrelated first post'), + array('php', 12, 'PHP 8.4 PHP only'), + array('random', 13, '[Random] No tags'), + array('reply', 10, 'Bug reply subject'), + array('moved', $this->moved_topic_id, 'Bug Moved topic'), ) as $post) { $sql = 'INSERT INTO phpbb_posts ' . $this->db->sql_build_array('INSERT', array( - 'post_id' => $post[0], 'topic_id' => $post[1], 'forum_id' => 2, 'post_subject' => $post[2], 'post_text' => '', )); $this->db->sql_query($sql); + $this->post_ids[$post[0]] = (int) $this->db->sql_nextid(); + } + + foreach (array( + 10 => $this->post_ids['both'], + 11 => $this->post_ids['unrelated'], + 12 => $this->post_ids['php'], + 13 => $this->post_ids['random'], + $this->moved_topic_id => $this->post_ids['moved'], + ) as $topic_id => $post_id) + { + $this->db->sql_query('UPDATE phpbb_topics + SET topic_first_post_id = ' . $post_id . ' + WHERE topic_id = ' . $topic_id); } } @@ -84,16 +103,16 @@ public function test_legacy_definitions_titles_subjects_and_idempotency() self::assertSame('4A76A8', $this->field('SELECT prefix_color FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_color')); self::assertSame(1, (int) $this->field('SELECT prefix_order FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_order')); self::assertSame('Both tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 10', 'topic_title')); - self::assertSame('Both tags', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 100', 'post_subject')); + self::assertSame('Both tags', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['both'], 'post_subject')); self::assertSame('PHP only', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 12', 'topic_title')); - self::assertSame('PHP only', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 102', 'post_subject')); + self::assertSame('PHP only', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['php'], 'post_subject')); self::assertSame('[Other] untouched', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 11', 'topic_title')); - self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 101', 'post_subject')); + self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['unrelated'], 'post_subject')); self::assertSame('[Random] No tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 13', 'topic_title')); - self::assertSame('Bug reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 104', 'post_subject')); - self::assertSame('Moved topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 14', 'topic_title')); - self::assertSame('Moved topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = 105', 'post_subject')); - self::assertSame(0, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = 14', 'total')); + self::assertSame('Bug reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['reply'], 'post_subject')); + self::assertSame('Moved topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = ' . $this->moved_topic_id, 'topic_title')); + self::assertSame('Moved topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['moved'], 'post_subject')); + self::assertSame(0, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . $this->moved_topic_id, 'total')); self::assertSame(1, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_forums WHERE forum_id = 2 AND prefix_id = 4', 'total')); $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = 10 AND prefix_id = 1'); diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index dc3aaf4..5b7ef23 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -101,8 +101,11 @@ public function test_multiple_tags_posting_display_and_filtering() self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); - $crawler = self::request('GET', 'search.php?author_id=2&sr=posts' . "&sid={$this->sid}"); - self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('.postprofile .topic-tag')->text()); + // Requires search_results_topic_title_prepend to be added to phpBB core. + // $crawler = self::request('GET', 'search.php?author_id=2&sr=posts' . "&sid={$this->sid}"); + // self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('.postprofile .topic-tag')->text()); + $crawler = self::request('GET', 'search.php?author_id=2&sr=topics' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); $crawler = self::request('GET', 'mcp.php?i=main&mode=forum_view&f=' . self::FORUM_ID . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); From 5b05fc9aa9231c13ed5b319b7308ac237541fcc2 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 06:59:05 -0700 Subject: [PATCH 08/32] Fix dates --- CHANGELOG.md | 2 +- event/display_listener.php | 2 +- event/lifecycle_listener.php | 2 +- event/posting_listener.php | 2 +- event/viewforum_listener.php | 2 +- migrations/v200_cleanup.php | 2 +- migrations/v200_data.php | 2 +- migrations/v200_schema.php | 2 +- tags/assignment_manager.php | 2 +- tags/filter.php | 2 +- tags/manager.php | 2 +- tags/renderer.php | 2 +- tests/controller/admin_controller_test.php | 2 +- tests/dbal/assignment_manager_test.php | 2 +- tests/dbal/filter_test.php | 2 +- tests/dbal/legacy_migration_test.php | 2 +- tests/dbal/tag_manager_test.php | 2 +- tests/dbal/tags_base.php | 2 +- tests/event/display_listener_test.php | 2 +- tests/event/lifecycle_listener_test.php | 2 +- tests/event/posting_listener_test.php | 2 +- tests/event/viewforum_listener_test.php | 2 +- tests/tags/renderer_test.php | 2 +- 23 files changed, 23 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c45565..b66be47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -### 2.0.0 - 2026-09-19 +### 2.0.0 - TBD - Replaced title text prefixes with relational topic tag metadata. - Added multiple colored tags per topic and automatic badge text contrast. diff --git a/event/display_listener.php b/event/display_listener.php index cdb24ae..5695902 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/event/lifecycle_listener.php b/event/lifecycle_listener.php index 9580b6a..244ef7b 100644 --- a/event/lifecycle_listener.php +++ b/event/lifecycle_listener.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/event/posting_listener.php b/event/posting_listener.php index 38dc01f..2e0cd3d 100644 --- a/event/posting_listener.php +++ b/event/posting_listener.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index 0c14a15..9fed81d 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/migrations/v200_cleanup.php b/migrations/v200_cleanup.php index 1810f27..5d9542e 100644 --- a/migrations/v200_cleanup.php +++ b/migrations/v200_cleanup.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/migrations/v200_data.php b/migrations/v200_data.php index ca37511..811648a 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/migrations/v200_schema.php b/migrations/v200_schema.php index 5bbf6e7..d92347e 100644 --- a/migrations/v200_schema.php +++ b/migrations/v200_schema.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index 578f65f..954e977 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tags/filter.php b/tags/filter.php index 2588502..20fd944 100644 --- a/tags/filter.php +++ b/tags/filter.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tags/manager.php b/tags/manager.php index fb36eae..90042d8 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * * @noinspection UnnecessaryCastingInspection diff --git a/tags/renderer.php b/tags/renderer.php index 3083aaa..a5d1ff6 100644 --- a/tags/renderer.php +++ b/tags/renderer.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index cb779e8..e25c188 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 50a3a66..fa51772 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/dbal/filter_test.php b/tests/dbal/filter_test.php index 0ecdbbf..dd1fea6 100644 --- a/tests/dbal/filter_test.php +++ b/tests/dbal/filter_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php index 8b60aee..79c029e 100644 --- a/tests/dbal/legacy_migration_test.php +++ b/tests/dbal/legacy_migration_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index 8a24c96..e34d942 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/dbal/tags_base.php b/tests/dbal/tags_base.php index 790e574..f77851e 100644 --- a/tests/dbal/tags_base.php +++ b/tests/dbal/tags_base.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php index 37ef5bf..c8934ef 100644 --- a/tests/event/display_listener_test.php +++ b/tests/event/display_listener_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/event/lifecycle_listener_test.php b/tests/event/lifecycle_listener_test.php index fdae5b3..1eaf5f4 100644 --- a/tests/event/lifecycle_listener_test.php +++ b/tests/event/lifecycle_listener_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/event/posting_listener_test.php b/tests/event/posting_listener_test.php index f178f21..6ad9ba5 100644 --- a/tests/event/posting_listener_test.php +++ b/tests/event/posting_listener_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index a512d6d..48f1d4b 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php index e3dac51..51d27c8 100644 --- a/tests/tags/renderer_test.php +++ b/tests/tags/renderer_test.php @@ -3,7 +3,7 @@ * * Topic Prefixes extension for the phpBB Forum Software package. * - * @copyright (c) 2016 phpBB Limited + * @copyright (c) 2026 phpBB Limited * @license GNU General Public License, version 2 (GPL-2.0) * */ From caf3bb7cbb1e5a10e5f768c6b432b71910be263d Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 07:11:26 -0700 Subject: [PATCH 09/32] Refactor functional tests --- tests/functional/functional_test.php | 215 ++++++++++++++++++++++----- 1 file changed, 175 insertions(+), 40 deletions(-) diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index 5b7ef23..9f63b76 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -10,8 +10,6 @@ namespace phpbb\topicprefixes\tests\functional; -use Symfony\Component\DomCrawler\Crawler; - /** @group functional */ class functional_test extends \phpbb_functional_test_case { @@ -28,7 +26,7 @@ protected function setUp(): void $this->add_lang_ext('phpbb/topicprefixes', array('acp_topic_prefixes', 'info_acp_topic_prefixes', 'topic_prefixes')); } - public function test_acp_module_and_create_edit_tag() + public function test_acp_module() { $this->login(); $this->admin_login(); @@ -37,10 +35,49 @@ public function test_acp_module_and_create_edit_tag() self::assertCount(1, $crawler->filter('input[type="color"]')); self::assertCount(1, $crawler->filter('select[name="forum_ids[]"][multiple]')); - $tag_id = $this->create_tag('Bug', '#d4351c', array(self::FORUM_ID)); + return true; + } + + /** + * @depends test_acp_module + */ + public function test_create_shared_tagged_topic($module_ready) + { + self::assertTrue($module_ready); + $this->login(); + $this->admin_login(); + $bug_id = $this->create_tag('Bug filter', '#d4351c', array(self::FORUM_ID)); + $php_id = $this->create_tag('PHP 8.4 filter', '#1d70b8', array(self::FORUM_ID)); + $topic = $this->create_topic(self::FORUM_ID, 'Structured tag title', 'Tagged first post', array( + 'topic_tags' => array($bug_id, $php_id), + 'topic_tags_present' => 1, + )); + + self::assertGreaterThan(0, $bug_id); + self::assertGreaterThan(0, $php_id); + self::assertNotEmpty($topic['topic_id']); + self::assertNotEmpty($topic['post_id']); + + return array( + 'bug_id' => $bug_id, + 'php_id' => $php_id, + 'topic_id' => (int) $topic['topic_id'], + 'post_id' => (int) $topic['post_id'], + ); + } + + /** + * @depends test_create_shared_tagged_topic + */ + public function test_acp_edit_tag($fixture) + { + $this->login(); + $this->admin_login(); + $tag_id = $fixture['bug_id']; + $crawler = $this->acp_page('action=edit&tag_id=' . $tag_id); $form = $crawler->selectButton($this->lang('SUBMIT'))->form(array( - 'tag_name' => 'Confirmed bug', + 'tag_name' => 'Confirmed bug filter', 'tag_color' => '#aa00cc', 'tag_enabled' => 1, 'forum_ids' => array(self::FORUM_ID), @@ -51,89 +88,188 @@ public function test_acp_module_and_create_edit_tag() $result = $this->db->sql_query('SELECT prefix_tag, prefix_color FROM phpbb_topic_prefixes WHERE prefix_id = ' . $tag_id); $row = $this->db->sql_fetchrow($result); $this->db->sql_freeresult($result); - self::assertSame('Confirmed bug', $row['prefix_tag']); + self::assertSame('Confirmed bug filter', $row['prefix_tag']); self::assertSame('AA00CC', $row['prefix_color']); + + return $fixture; } - public function test_multiple_tags_posting_display_and_filtering() + /** + * @depends test_acp_edit_tag + */ + public function test_posting_form_displays_tag_controls($fixture) { $this->login(); - $this->admin_login(); - $bug_id = $this->create_tag('Bug filter', '#d4351c', array(self::FORUM_ID)); - $php_id = $this->create_tag('PHP 8.4 filter', '#1d70b8', array(self::FORUM_ID)); - $crawler = self::request('GET', 'posting.php?mode=post&f=' . self::FORUM_ID . "&sid={$this->sid}"); self::assertGreaterThanOrEqual(2, $crawler->filter('input[name="topic_tags[]"]')->count()); - $topic = $this->create_topic(self::FORUM_ID, 'Structured tag title', 'Tagged first post', array( - 'topic_tags' => array($bug_id, $php_id), - 'topic_tags_present' => 1, - )); + return $fixture; + } + + /** + * @depends test_posting_form_displays_tag_controls + */ + public function test_topic_title_is_not_modified($fixture) + { $this->get_db(); - $result = $this->db->sql_query('SELECT topic_title, topic_first_post_id FROM phpbb_topics WHERE topic_id = ' . (int) $topic['topic_id']); - $row = $this->db->sql_fetchrow($result); + $result = $this->db->sql_query('SELECT topic_title FROM phpbb_topics WHERE topic_id = ' . $fixture['topic_id']); + self::assertSame('Structured tag title', $this->db->sql_fetchfield('topic_title')); $this->db->sql_freeresult($result); - self::assertSame('Structured tag title', $row['topic_title']); - $result = $this->db->sql_query('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . (int) $row['topic_first_post_id']); + + return $fixture; + } + + /** + * @depends test_topic_title_is_not_modified + */ + public function test_first_post_subject_is_not_modified($fixture) + { + $this->get_db(); + $result = $this->db->sql_query('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $fixture['post_id']); self::assertSame('Structured tag title', $this->db->sql_fetchfield('post_subject')); $this->db->sql_freeresult($result); - $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . (int) $topic['topic_id'] . ' ORDER BY prefix_id'); - self::assertSame(array($bug_id, $php_id), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); + + return $fixture; + } + + /** + * @depends test_first_post_subject_is_not_modified + */ + public function test_multiple_tag_assignments_are_stored($fixture) + { + $this->get_db(); + $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . $fixture['topic_id'] . ' ORDER BY prefix_id'); + self::assertSame(array($fixture['bug_id'], $fixture['php_id']), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); $this->db->sql_freeresult($result); - $crawler = self::request('GET', 'posting.php?mode=edit&f=' . self::FORUM_ID . '&p=' . (int) $row['topic_first_post_id'] . "&sid={$this->sid}"); + return $fixture; + } + + /** + * @depends test_multiple_tag_assignments_are_stored + */ + public function test_first_post_edit_updates_tag_assignments($fixture) + { + $this->login(); + $crawler = self::request('GET', 'posting.php?mode=edit&f=' . self::FORUM_ID . '&p=' . $fixture['post_id'] . "&sid={$this->sid}"); $form = $crawler->selectButton($this->lang('SUBMIT'))->form(); $values = $form->getPhpValues(); - $values['topic_tags'] = array((string) $php_id); + $values['topic_tags'] = array((string) $fixture['php_id']); $values['topic_tags_present'] = '1'; self::$client->request('POST', $form->getUri(), $values); self::assert_response_html(); - $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . (int) $topic['topic_id'] . ' ORDER BY prefix_id'); - self::assertSame(array($php_id), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); + $this->get_db(); + $result = $this->db->sql_query('SELECT prefix_id FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . $fixture['topic_id'] . ' ORDER BY prefix_id'); + self::assertSame(array($fixture['php_id']), array_map('intval', array_column($this->db->sql_fetchrowset($result), 'prefix_id'))); $this->db->sql_freeresult($result); - $crawler = self::request('GET', 'viewtopic.php?t=' . (int) $topic['topic_id'] . "&sid={$this->sid}"); + return $fixture; + } + + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_viewtopic_displays_tags($fixture) + { + $this->login(); + $crawler = self::request('GET', 'viewtopic.php?t=' . $fixture['topic_id'] . "&sid={$this->sid}"); self::assertCount(1, $crawler->filter('h2.topic-title .topic-tag')); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('h2.topic-title .topic-tag')->text()); self::assertStringContainsString('topic-tags', $crawler->filter('h2.topic-title')->children()->eq(0)->attr('class')); + } - $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . '&tags=' . $php_id . "&sid={$this->sid}"); + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_viewforum_filters_by_tag($fixture) + { + $this->login(); + $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . '&tags=' . $fixture['php_id'] . "&sid={$this->sid}"); self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); + } - // Requires search_results_topic_title_prepend to be added to phpBB core. - // $crawler = self::request('GET', 'search.php?author_id=2&sr=posts' . "&sid={$this->sid}"); - // self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('.postprofile .topic-tag')->text()); + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_search_topic_results_display_tags($fixture) + { + $this->login(); $crawler = self::request('GET', 'search.php?author_id=2&sr=topics' . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + } + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_search_post_results_display_tags($fixture) + { + $this->markTestSkipped('Requires search_results_topic_title_prepend to be added to phpBB core.'); + $this->login(); + $crawler = self::request('GET', 'search.php?author_id=2&sr=posts' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('.postprofile .topic-tag')->text()); + } + + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_mcp_forum_displays_tags($fixture) + { + $this->login(); $crawler = self::request('GET', 'mcp.php?i=main&mode=forum_view&f=' . self::FORUM_ID . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + } + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_ucp_subscribed_topics_display_tags($fixture) + { + $this->markTestSkipped('Requires topiclist_row_prepend to be added to phpBB core ucp_main_subscribed.html.'); + $this->login(); + $this->get_db(); $this->db->sql_query('DELETE FROM phpbb_topics_watch - WHERE topic_id = ' . (int) $topic['topic_id'] . ' + WHERE topic_id = ' . $fixture['topic_id'] . ' AND user_id = 2'); $this->db->sql_query('INSERT INTO phpbb_topics_watch ' . $this->db->sql_build_array('INSERT', array( - 'topic_id' => (int) $topic['topic_id'], + 'topic_id' => $fixture['topic_id'], 'user_id' => 2, 'notify_status' => 0, ))); + $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=subscribed' . "&sid={$this->sid}"); + self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + } + + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_ucp_bookmarks_display_tags($fixture) + { + $this->markTestSkipped('Requires topiclist_row_prepend to be added to phpBB core ucp_main_bookmarks.html.'); + $this->login(); + $this->get_db(); $this->db->sql_query('DELETE FROM phpbb_bookmarks - WHERE topic_id = ' . (int) $topic['topic_id'] . ' + WHERE topic_id = ' . $fixture['topic_id'] . ' AND user_id = 2'); $this->db->sql_query('INSERT INTO phpbb_bookmarks ' . $this->db->sql_build_array('INSERT', array( - 'topic_id' => (int) $topic['topic_id'], + 'topic_id' => $fixture['topic_id'], 'user_id' => 2, ))); - - $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=subscribed' . "&sid={$this->sid}"); - self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=bookmarks' . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); + } + /** + * @depends test_first_post_edit_updates_tag_assignments + */ + public function test_ucp_front_displays_tags($fixture) + { + $this->markTestSkipped('Requires topiclist_row_prepend to be added to phpBB core ucp_main_front.html.'); + $this->login(); + $this->get_db(); $this->db->sql_query('UPDATE phpbb_topics - SET topic_type = 3 - WHERE topic_id = ' . (int) $topic['topic_id']); + SET topic_type = ' . POST_GLOBAL . ' + WHERE topic_id = ' . $fixture['topic_id']); $crawler = self::request('GET', 'ucp.php?i=ucp_main&mode=front' . "&sid={$this->sid}"); self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('ul.topiclist .topic-tag')->text()); } @@ -147,7 +283,6 @@ protected function create_tag($name, $color, array $forum_ids) 'tag_enabled' => 1, 'forum_ids' => $forum_ids, )); - /** @var Crawler $crawler */ $crawler = self::submit($form); $this->assertContainsLang('TOPIC_TAG_SAVED', $crawler->text()); From d94fd0034113a289ebb538a684b691e73299e30d Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 07:43:54 -0700 Subject: [PATCH 10/32] Performance improvements pass --- config/services.yml | 1 + event/display_listener.php | 9 +- event/lifecycle_listener.php | 13 +- event/posting_listener.php | 7 +- event/viewforum_listener.php | 10 +- tags/assignment_manager.php | 335 ++++++++++++++++++++---- tags/filter.php | 14 +- tags/manager.php | 216 +++++++++------ tags/renderer.php | 16 +- tests/dbal/assignment_manager_test.php | 56 +++- tests/dbal/filter_test.php | 3 + tests/dbal/tag_manager_test.php | 25 ++ tests/dbal/tags_base.php | 7 +- tests/event/display_listener_test.php | 8 +- tests/event/lifecycle_listener_test.php | 21 +- tests/event/posting_listener_test.php | 20 +- tests/event/viewforum_listener_test.php | 8 +- 17 files changed, 567 insertions(+), 202 deletions(-) diff --git a/config/services.yml b/config/services.yml index b3adc6f..41bb36d 100644 --- a/config/services.yml +++ b/config/services.yml @@ -63,6 +63,7 @@ services: - '%phpbb.topicprefixes.tables.topic_prefixes_forums%' - '%phpbb.topicprefixes.tables.topic_prefixes_topics%' - '%tables.forums%' + - '@cache.driver' phpbb.topicprefixes.tags.assignment_manager: class: phpbb\topicprefixes\tags\assignment_manager diff --git a/event/display_listener.php b/event/display_listener.php index 5695902..de0339d 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -35,9 +35,6 @@ class display_listener implements EventSubscriberInterface /** @var array Tags grouped by MCP topic */ protected $mcp_tags = []; - /** @var array Effective tag-bearing topic IDs keyed by MCP topic ID */ - protected $mcp_tag_topic_ids = []; - /** @var array Tags grouped by UCP topic */ protected $ucp_tags = []; @@ -129,8 +126,7 @@ public function add_search_tags($event): void */ public function load_mcp_tags($event): void { - $this->mcp_tag_topic_ids = $this->assignments->get_effective_topic_ids($event['topic_list']); - $this->mcp_tags = $this->assignments->get_tags_for_topics(array_values($this->mcp_tag_topic_ids)); + $this->mcp_tags = $this->assignments->get_tags_for_displayed_topics($event['topic_list']); } /** @@ -142,10 +138,9 @@ public function load_mcp_tags($event): void public function add_mcp_tags($event): void { $topic_id = (int) $event['row']['topic_id']; - $tag_topic_id = $this->mcp_tag_topic_ids[$topic_id] ?? $topic_id; $topic_row = $event['topic_row']; $topic_row['MCP_TOPIC_TAGS'] = $this->renderer->render( - $this->mcp_tags[$tag_topic_id] ?? [], + $this->mcp_tags[$topic_id] ?? [], (int) $event['row']['forum_id'] ); $event['topic_row'] = $topic_row; diff --git a/event/lifecycle_listener.php b/event/lifecycle_listener.php index 244ef7b..550afe0 100644 --- a/event/lifecycle_listener.php +++ b/event/lifecycle_listener.php @@ -125,12 +125,12 @@ public function copy_split_tags($event): void $source_topic_id = (int) $event['topic_id']; if (isset($this->deleted_topic_tags[$source_topic_id])) { - $this->assignments->set_topic_tags((int) $event['to_topic_id'], $this->deleted_topic_tags[$source_topic_id]); + $this->assignments->set_validated_topic_tags((int) $event['to_topic_id'], $this->deleted_topic_tags[$source_topic_id]); unset($this->deleted_topic_tags[$source_topic_id]); return; } - $this->assignments->copy_topic_tags($source_topic_id, (int) $event['to_topic_id']); + $this->assignments->copy_tags_to_new_topics([$source_topic_id => (int) $event['to_topic_id']]); } /** @@ -148,7 +148,7 @@ public function copy_fork_tags($event): void } $this->processed_forks[$new_topic_id] = true; - $this->assignments->copy_topic_tags((int) $event['row']['topic_id'], $new_topic_id); + $this->assignments->copy_tags_to_new_topics([(int) $event['row']['topic_id'] => $new_topic_id]); } /** @@ -187,10 +187,7 @@ public function merge_posts_tags($event): void */ public function copy_relocated_post_tags($event): void { - foreach ($event['new_topic_id_map'] as $source_topic_id => $new_topic_id) - { - $this->assignments->copy_topic_tags((int) $source_topic_id, (int) $new_topic_id); - } + $this->assignments->copy_tags_to_new_topics($event['new_topic_id_map']); } /** @@ -212,7 +209,7 @@ protected function merge_deleted_tags(array $source_topic_ids, int $target_topic if ($tag_ids) { - $this->assignments->add_topic_tags($target_topic_id, $tag_ids); + $this->assignments->add_validated_topic_tags($target_topic_id, $tag_ids); } } } diff --git a/event/posting_listener.php b/event/posting_listener.php index 2e0cd3d..daf4ee7 100644 --- a/event/posting_listener.php +++ b/event/posting_listener.php @@ -126,8 +126,9 @@ public function add_to_posting_form($event): void $assigned = []; if ($event['mode'] === 'edit') { - $topic_tags = $this->assignments->get_tags_for_topics([$event['topic_id']]); - $assigned = $topic_tags[(int) $event['topic_id']] ?? []; + $assigned = $this->manager->get_tags_by_ids( + $this->assignments->get_topic_tag_ids((int) $event['topic_id']) + ); $available += $assigned; uasort($available, [$this, 'compare_tags']); } @@ -198,7 +199,7 @@ public function save_assignments($event): void return; } - $this->assignments->set_topic_tags((int) $event['data']['topic_id'], $this->submitted_tag_ids); + $this->assignments->set_validated_topic_tags((int) $event['data']['topic_id'], $this->submitted_tag_ids); $this->submitted_tag_ids = null; } diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index 9fed81d..84a71fe 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -108,9 +108,15 @@ public function configure_filter($event): void $this->forum_id = (int) $event['forum_id']; $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); - $available = $this->manager->get_available_tags($this->forum_id); $forum_tags = $this->manager->get_available_tags($this->forum_id, false); - $assigned = $this->assignments->get_tags_for_forum($this->forum_id); + $available = array_filter($forum_tags, function ($tag) { + return !empty($tag['prefix_enabled']); + }); + $unavailable_ids = $this->manager->get_unavailable_tag_ids($this->forum_id); + $assigned_ids = $unavailable_ids + ? $this->assignments->get_tag_ids_for_forum($this->forum_id, $unavailable_ids) + : []; + $assigned = $this->manager->get_tags_by_ids($assigned_ids); $filterable = $forum_tags + $assigned; // Include tags preserved on topics moved from another forum. diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index 954e977..ebd5087 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -29,6 +29,18 @@ class assignment_manager /** @var string Topics table */ protected $topics_table; + /** @var array Request-local tag IDs grouped by topic */ + protected $topic_tag_ids = []; + + /** @var array Topics whose relationship IDs have been loaded */ + protected $topic_tag_ids_loaded = []; + + /** @var array Request-local tag definitions grouped by topic */ + protected $topic_tags = []; + + /** @var array Topics whose tag definitions have been loaded */ + protected $topic_tags_loaded = []; + /** * Constructor. * @@ -57,16 +69,62 @@ public function __construct(driver_interface $db, $topic_map_table, $tags_table, public function set_topic_tags(int $topic_id, array $tag_ids): bool { $topic_id = (int) $topic_id; - $tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids)))); + $tag_ids = $this->normalize_ids($tag_ids); if (!$topic_id || !$this->topic_exists($topic_id) || !$this->tags_exist($tag_ids)) { return false; } + return $this->replace_topic_tags($topic_id, $tag_ids); + } + + /** + * Replace assignments already validated by the posting workflow. + * + * This avoids repeating topic/tag existence queries after phpBB created the + * topic and the tag manager validated every submitted identifier. + * + * @param int $topic_id Topic identifier + * @param array $tag_ids Validated tag identifiers + * @return bool Whether a valid topic identifier was supplied + */ + public function set_validated_topic_tags(int $topic_id, array $tag_ids): bool + { + $topic_id = (int) $topic_id; + if (!$topic_id) + { + return false; + } + + return $this->replace_topic_tags($topic_id, $this->normalize_ids($tag_ids)); + } + + /** + * Apply only changed topic/tag relationships. + * + * @param int $topic_id Topic identifier + * @param array $tag_ids Normalized tag identifiers + * @return bool True + */ + protected function replace_topic_tags(int $topic_id, array $tag_ids): bool + { + $current = $this->get_topic_tag_ids($topic_id); + $remove = array_values(array_diff($current, $tag_ids)); + $add = array_values(array_diff($tag_ids, $current)); + if (!$remove && !$add) + { + return true; + } + $this->db->sql_transaction('begin'); - $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' WHERE topic_id = ' . $topic_id); + if ($remove) + { + $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' + WHERE topic_id = ' . $topic_id . ' + AND ' . $this->db->sql_in_set('prefix_id', $remove)); + } $rows = []; - foreach ($tag_ids as $tag_id) + foreach ($add as $tag_id) { $rows[] = ['topic_id' => $topic_id, 'prefix_id' => $tag_id]; } @@ -75,6 +133,9 @@ public function set_topic_tags(int $topic_id, array $tag_ids): bool $this->db->sql_multi_insert($this->topic_map_table, $rows); } $this->db->sql_transaction('commit'); + $this->topic_tag_ids[$topic_id] = $tag_ids; + $this->topic_tag_ids_loaded[$topic_id] = true; + unset($this->topic_tags[$topic_id], $this->topic_tags_loaded[$topic_id]); return true; } @@ -91,6 +152,21 @@ public function add_topic_tags(int $topic_id, array $tag_ids): bool return $this->set_topic_tags($topic_id, array_merge($this->get_topic_tag_ids($topic_id), $tag_ids)); } + /** + * Add relationships already validated by a phpBB lifecycle operation. + * + * @param int $topic_id Topic identifier + * @param array $tag_ids Validated tag identifiers + * @return bool Whether a valid topic identifier was supplied + */ + public function add_validated_topic_tags(int $topic_id, array $tag_ids): bool + { + return $this->set_validated_topic_tags( + $topic_id, + array_merge($this->get_topic_tag_ids($topic_id), $tag_ids) + ); + } + /** * Copy every assignment from one topic to another. * @@ -108,6 +184,65 @@ public function copy_topic_tags(int $source_topic_id, int $target_topic_id): boo return $this->set_topic_tags($target_topic_id, $this->get_topic_tag_ids($source_topic_id)); } + /** + * Batch-copy relationships to topics newly created by phpBB. + * + * Target topics must not already have tag relationships. Source relationships + * already guarantee valid tag identifiers, so no per-topic validation is run. + * + * @param array $topic_id_map Target topic IDs keyed by source topic ID + * @return void + */ + public function copy_tags_to_new_topics(array $topic_id_map): void + { + $map = []; + foreach ($topic_id_map as $source_topic_id => $target_topic_id) + { + $source_topic_id = (int) $source_topic_id; + $target_topic_id = (int) $target_topic_id; + if ($source_topic_id && $target_topic_id && $source_topic_id !== $target_topic_id) + { + $map[$source_topic_id] = $target_topic_id; + } + } + if (!$map) + { + return; + } + + $source_tags = $this->get_topic_tag_ids_for_topics(array_keys($map)); + $target_tags = []; + foreach ($map as $source_topic_id => $target_topic_id) + { + foreach ($source_tags[$source_topic_id] ?? [] as $tag_id) + { + $target_tags[$target_topic_id][$tag_id] = true; + } + } + + $rows = []; + foreach ($target_tags as $target_topic_id => $tag_ids) + { + foreach (array_keys($tag_ids) as $tag_id) + { + $rows[] = ['topic_id' => $target_topic_id, 'prefix_id' => $tag_id]; + } + } + if ($rows) + { + $this->db->sql_multi_insert($this->topic_map_table, $rows); + } + + foreach ($map as $target_topic_id) + { + $tag_ids = array_keys($target_tags[$target_topic_id] ?? []); + sort($tag_ids, SORT_NUMERIC); + $this->topic_tag_ids[$target_topic_id] = $tag_ids; + $this->topic_tag_ids_loaded[$target_topic_id] = true; + unset($this->topic_tags[$target_topic_id], $this->topic_tags_loaded[$target_topic_id]); + } + } + /** * Delete assignments belonging to topics. * @@ -116,11 +251,18 @@ public function copy_topic_tags(int $source_topic_id, int $target_topic_id): boo */ public function delete_topic_assignments(array $topic_ids): void { - $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + $topic_ids = $this->normalize_ids($topic_ids); if ($topic_ids) { $this->db->sql_query('DELETE FROM ' . $this->topic_map_table . ' WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids)); + foreach ($topic_ids as $topic_id) + { + $this->topic_tag_ids[$topic_id] = []; + $this->topic_tag_ids_loaded[$topic_id] = true; + $this->topic_tags[$topic_id] = []; + $this->topic_tags_loaded[$topic_id] = true; + } } } @@ -139,6 +281,7 @@ public function delete_forum_topic_assignments(int $forum_id): void WHERE forum_id = ' . (int) $forum_id . ' )'; $this->db->sql_query($sql); + $this->clear_request_cache(); } /** @@ -149,8 +292,8 @@ public function delete_forum_topic_assignments(int $forum_id): void */ public function get_topic_tag_ids(int $topic_id): array { - $tags = $this->get_tags_for_topics([$topic_id]); - return isset($tags[$topic_id]) ? array_keys($tags[$topic_id]) : []; + $tag_ids = $this->get_topic_tag_ids_for_topics([$topic_id]); + return $tag_ids[$topic_id] ?? []; } /** @@ -161,25 +304,44 @@ public function get_topic_tag_ids(int $topic_id): array */ public function get_topic_tag_ids_for_topics(array $topic_ids): array { - $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + $topic_ids = $this->normalize_ids($topic_ids); if (!$topic_ids) { return []; } - $sql = 'SELECT topic_id, prefix_id - FROM ' . $this->topic_map_table . ' - WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids) . ' - ORDER BY topic_id ASC, prefix_id ASC'; - $result = $this->db->sql_query($sql); - $tag_ids = []; - while ($row = $this->db->sql_fetchrow($result)) + $missing = array_values(array_filter($topic_ids, function ($topic_id) { + return !isset($this->topic_tag_ids_loaded[$topic_id]); + })); + if ($missing) { - $tag_ids[(int) $row['topic_id']][] = (int) $row['prefix_id']; + foreach ($missing as $topic_id) + { + $this->topic_tag_ids[$topic_id] = []; + $this->topic_tag_ids_loaded[$topic_id] = true; + } + $sql = 'SELECT topic_id, prefix_id + FROM ' . $this->topic_map_table . ' + WHERE ' . $this->db->sql_in_set('topic_id', $missing) . ' + ORDER BY topic_id ASC, prefix_id ASC'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $this->topic_tag_ids[(int) $row['topic_id']][] = (int) $row['prefix_id']; + } + $this->db->sql_freeresult($result); } - $this->db->sql_freeresult($result); - return $tag_ids; + $return = []; + foreach ($topic_ids as $topic_id) + { + if ($this->topic_tag_ids[$topic_id]) + { + $return[$topic_id] = $this->topic_tag_ids[$topic_id]; + } + } + + return $return; } /** @@ -190,94 +352,130 @@ public function get_topic_tag_ids_for_topics(array $topic_ids): array */ public function get_tags_for_topics(array $topic_ids): array { - $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + $topic_ids = $this->normalize_ids($topic_ids); if (!$topic_ids) { return []; } - $sql = 'SELECT pt.topic_id, p.* - FROM ' . $this->topic_map_table . ' pt - INNER JOIN ' . $this->tags_table . ' p - ON p.prefix_id = pt.prefix_id - WHERE ' . $this->db->sql_in_set('pt.topic_id', $topic_ids) . ' - ORDER BY p.prefix_order ASC, p.prefix_id ASC'; - $result = $this->db->sql_query($sql); - $tags = []; - while ($row = $this->db->sql_fetchrow($result)) + $missing = array_values(array_filter($topic_ids, function ($topic_id) { + return !isset($this->topic_tags_loaded[$topic_id]); + })); + if ($missing) { - $topic_id = (int) $row['topic_id']; - $tag_id = (int) $row['prefix_id']; - $tags[$topic_id][$tag_id] = $row; + foreach ($missing as $topic_id) + { + $this->topic_tags[$topic_id] = []; + $this->topic_tags_loaded[$topic_id] = true; + $this->topic_tag_ids[$topic_id] = []; + $this->topic_tag_ids_loaded[$topic_id] = true; + } + $sql = 'SELECT pt.topic_id, p.* + FROM ' . $this->topic_map_table . ' pt + INNER JOIN ' . $this->tags_table . ' p + ON p.prefix_id = pt.prefix_id + WHERE ' . $this->db->sql_in_set('pt.topic_id', $missing) . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $topic_id = (int) $row['topic_id']; + $tag_id = (int) $row['prefix_id']; + $this->topic_tags[$topic_id][$tag_id] = $row; + $this->topic_tag_ids[$topic_id][] = $tag_id; + } + $this->db->sql_freeresult($result); } - $this->db->sql_freeresult($result); - return $tags; + $return = []; + foreach ($topic_ids as $topic_id) + { + if ($this->topic_tags[$topic_id]) + { + $return[$topic_id] = $this->topic_tags[$topic_id]; + } + } + + return $return; } /** - * Resolve shadow topic identifiers to their destination topics. + * Batch-load tags keyed by displayed topic, resolving move shadows in SQL. * * @param array $topic_ids Topic or shadow-topic identifiers - * @return array Effective topic identifiers keyed by requested identifier + * @return array Tags grouped by displayed topic identifier */ - public function get_effective_topic_ids(array $topic_ids): array + public function get_tags_for_displayed_topics(array $topic_ids): array { - $topic_ids = array_values(array_unique(array_filter(array_map('intval', $topic_ids)))); + $topic_ids = $this->normalize_ids($topic_ids); if (!$topic_ids) { return []; } - $sql = 'SELECT topic_id, topic_moved_id - FROM ' . $this->topics_table . ' - WHERE ' . $this->db->sql_in_set('topic_id', $topic_ids); + $effective_topic_id = $this->db->sql_case( + 't.topic_moved_id <> 0', + 't.topic_moved_id', + 't.topic_id' + ); + $sql = 'SELECT t.topic_id AS display_topic_id, p.* + FROM ' . $this->topics_table . ' t + INNER JOIN ' . $this->topic_map_table . ' pt + ON pt.topic_id = ' . $effective_topic_id . ' + INNER JOIN ' . $this->tags_table . ' p + ON p.prefix_id = pt.prefix_id + WHERE ' . $this->db->sql_in_set('t.topic_id', $topic_ids) . ' + ORDER BY p.prefix_order ASC, p.prefix_id ASC'; $result = $this->db->sql_query($sql); - $resolved = []; + $tags = []; while ($row = $this->db->sql_fetchrow($result)) { - $topic_id = (int) $row['topic_id']; - $resolved[$topic_id] = !empty($row['topic_moved_id']) ? (int) $row['topic_moved_id'] : $topic_id; + $topic_id = (int) $row['display_topic_id']; + $tag_id = (int) $row['prefix_id']; + unset($row['display_topic_id']); + $tags[$topic_id][$tag_id] = $row; } $this->db->sql_freeresult($result); - return $resolved; + return $tags; } /** - * Get tags assigned to topics displayed in one forum. + * Get tag IDs assigned to topics displayed in one forum. * * Includes tags that are disabled or unavailable for new assignments in the * forum, tags reached through move shadows, and tags on global announcements. * - * @param int $forum_id Forum identifier - * @return array Tags keyed by identifier + * @param int $forum_id Forum identifier + * @param array $candidate_ids Optional tag identifiers to examine + * @return array Tag identifiers */ - public function get_tags_for_forum(int $forum_id): array + public function get_tag_ids_for_forum(int $forum_id, array $candidate_ids = []): array { + $candidate_ids = $this->normalize_ids($candidate_ids); $effective_topic_id = $this->db->sql_case( 't.topic_moved_id <> 0', 't.topic_moved_id', 't.topic_id' ); - $sql = 'SELECT DISTINCT p.* + $sql = 'SELECT DISTINCT pt.prefix_id FROM ' . $this->topic_map_table . ' pt INNER JOIN ' . $this->topics_table . ' t ON pt.topic_id = ' . $effective_topic_id . ' - INNER JOIN ' . $this->tags_table . ' p - ON p.prefix_id = pt.prefix_id WHERE (t.forum_id = ' . (int) $forum_id . ' - OR t.topic_type = ' . POST_GLOBAL . ') - ORDER BY p.prefix_order ASC, p.prefix_id ASC'; + OR t.topic_type = ' . POST_GLOBAL . ')' . + ($candidate_ids ? ' + AND ' . $this->db->sql_in_set('pt.prefix_id', $candidate_ids) : '') . ' + ORDER BY pt.prefix_id ASC'; $result = $this->db->sql_query($sql); - $tags = []; + $tag_ids = []; while ($row = $this->db->sql_fetchrow($result)) { - $tags[(int) $row['prefix_id']] = $row; + $tag_ids[] = (int) $row['prefix_id']; } $this->db->sql_freeresult($result); - return $tags; + return $tag_ids; } /** @@ -320,4 +518,31 @@ protected function tags_exist(array $tag_ids): bool return $total === count($tag_ids); } + + /** + * Normalize an identifier list. + * + * @param array $ids Identifiers + * @return array Positive unique identifiers + */ + protected function normalize_ids(array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + sort($ids, SORT_NUMERIC); + + return $ids; + } + + /** + * Drop request-local relationship data after a bulk mutation. + * + * @return void + */ + protected function clear_request_cache(): void + { + $this->topic_tag_ids = []; + $this->topic_tag_ids_loaded = []; + $this->topic_tags = []; + $this->topic_tags_loaded = []; + } } diff --git a/tags/filter.php b/tags/filter.php index 20fd944..8bae53d 100644 --- a/tags/filter.php +++ b/tags/filter.php @@ -66,12 +66,18 @@ public function condition(string $topic_alias, array $tag_ids): string $topic_alias . '.topic_id' ); - return $topic_id . ' IN ( - SELECT tpf.topic_id + $subquery = 'SELECT tpf.topic_id FROM ' . $this->topic_map_table . ' tpf - WHERE ' . $this->db->sql_in_set('tpf.prefix_id', $tag_ids) . ' + WHERE ' . $this->db->sql_in_set('tpf.prefix_id', $tag_ids); + if (count($tag_ids) > 1) + { + $subquery .= ' GROUP BY tpf.topic_id - HAVING COUNT(tpf.prefix_id) = ' . count($tag_ids) . ' + HAVING COUNT(tpf.prefix_id) = ' . count($tag_ids); + } + + return $topic_id . ' IN ( + ' . $subquery . ' )'; } diff --git a/tags/manager.php b/tags/manager.php index 90042d8..4950586 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -13,6 +13,7 @@ namespace phpbb\topicprefixes\tags; +use phpbb\cache\driver\driver_interface as cache; use phpbb\db\driver\driver_interface; /** @@ -21,6 +22,7 @@ class manager { const DEFAULT_COLOR = '4A76A8'; + const CACHE_KEY = '_topicprefixes_tag_catalog'; /** @var driver_interface */ protected $db; @@ -37,22 +39,30 @@ class manager /** @var string Forums table */ protected $forums_table; + /** @var cache|null */ + protected $cache; + + /** @var array|null Request-local tag catalog */ + protected $catalog; + /** * Constructor. * - * @param driver_interface $db Database connection - * @param string $tags_table Tag definition table - * @param string $forums_map_table Forum/tag map table - * @param string $topic_map_table Topic/tag map table - * @param string $forums_table Forums table + * @param driver_interface $db Database connection + * @param string $tags_table Tag definition table + * @param string $forums_map_table Forum/tag map table + * @param string $topic_map_table Topic/tag map table + * @param string $forums_table Forums table + * @param cache|null $cache Cache driver */ - public function __construct(driver_interface $db, $tags_table, $forums_map_table, $topic_map_table, $forums_table) + public function __construct(driver_interface $db, $tags_table, $forums_map_table, $topic_map_table, $forums_table, cache $cache = null) { $this->db = $db; $this->tags_table = $tags_table; $this->forums_map_table = $forums_map_table; $this->topic_map_table = $topic_map_table; $this->forums_table = $forums_table; + $this->cache = $cache; } /** @@ -63,19 +73,17 @@ public function __construct(driver_interface $db, $tags_table, $forums_map_table */ public function get_tag(int $tag_id) { - $sql = 'SELECT * - FROM ' . $this->tags_table . ' - WHERE prefix_id = ' . (int) $tag_id; - $result = $this->db->sql_query($sql); - $row = $this->db->sql_fetchrow($result); - $this->db->sql_freeresult($result); - - if ($row) + $catalog = $this->get_catalog(); + $tag_id = (int) $tag_id; + if (!isset($catalog['tags'][$tag_id])) { - $row['forum_ids'] = $this->get_forum_ids($row['prefix_id']); + return false; } - return $row; + $tag = $catalog['tags'][$tag_id]; + $tag['forum_ids'] = $catalog['tag_forums'][$tag_id] ?? []; + + return $tag; } /** @@ -85,18 +93,19 @@ public function get_tag(int $tag_id) */ public function get_tags(): array { - $sql = 'SELECT * - FROM ' . $this->tags_table . ' - ORDER BY prefix_order ASC, prefix_id ASC'; - $result = $this->db->sql_query($sql); - $tags = []; - while ($row = $this->db->sql_fetchrow($result)) - { - $tags[(int) $row['prefix_id']] = $row; - } - $this->db->sql_freeresult($result); + return $this->get_catalog()['tags']; + } - return $tags; + /** + * Get selected tag definitions from the cached catalog. + * + * @param array $tag_ids Tag identifiers + * @return array Tags keyed by identifier + */ + public function get_tags_by_ids(array $tag_ids): array + { + $requested = array_fill_keys(array_map('intval', $tag_ids), true); + return array_intersect_key($this->get_tags(), $requested); } /** @@ -108,22 +117,26 @@ public function get_tags(): array */ public function get_available_tags(int $forum_id, bool $enabled_only = true): array { - $sql = 'SELECT p.* - FROM ' . $this->tags_table . ' p - INNER JOIN ' . $this->forums_map_table . ' pf - ON pf.prefix_id = p.prefix_id - WHERE pf.forum_id = ' . (int) $forum_id . - ($enabled_only ? ' AND p.prefix_enabled = 1' : '') . ' - ORDER BY p.prefix_order ASC, p.prefix_id ASC'; - $result = $this->db->sql_query($sql); - $tags = []; - while ($row = $this->db->sql_fetchrow($result)) - { - $tags[(int) $row['prefix_id']] = $row; - } - $this->db->sql_freeresult($result); + $catalog = $this->get_catalog(); + $forum_tags = $catalog['forum_tags'][(int) $forum_id] ?? []; + return array_filter($catalog['tags'], static function ($tag, $tag_id) use ($enabled_only, $forum_tags) { + return isset($forum_tags[$tag_id]) && (!$enabled_only || !empty($tag['prefix_enabled'])); + }, ARRAY_FILTER_USE_BOTH); + } - return $tags; + /** + * Get definitions unavailable for new assignments in one forum. + * + * @param int $forum_id Forum identifier + * @return array Tag identifiers + */ + public function get_unavailable_tag_ids(int $forum_id): array + { + $catalog = $this->get_catalog(); + return array_keys(array_diff_key( + $catalog['tags'], + $catalog['forum_tags'][(int) $forum_id] ?? [] + )); } /** @@ -141,23 +154,12 @@ public function get_assignable_tags(int $forum_id, array $tag_ids): array return []; } - $sql = 'SELECT p.* - FROM ' . $this->tags_table . ' p - INNER JOIN ' . $this->forums_map_table . ' pf - ON pf.prefix_id = p.prefix_id - WHERE pf.forum_id = ' . (int) $forum_id . ' - AND p.prefix_enabled = 1 - AND ' . $this->db->sql_in_set('p.prefix_id', $tag_ids) . ' - ORDER BY p.prefix_order ASC, p.prefix_id ASC'; - $result = $this->db->sql_query($sql); - $tags = []; - while ($row = $this->db->sql_fetchrow($result)) - { - $tags[(int) $row['prefix_id']] = $row; - } - $this->db->sql_freeresult($result); - - return $tags; + $catalog = $this->get_catalog(); + $forum_tags = $catalog['forum_tags'][(int) $forum_id] ?? []; + $submitted = array_fill_keys($tag_ids, true); + return array_filter($catalog['tags'], static function ($tag, $tag_id) use ($submitted, $forum_tags) { + return isset($submitted[$tag_id], $forum_tags[$tag_id]) && !empty($tag['prefix_enabled']); + }, ARRAY_FILTER_USE_BOTH); } /** @@ -194,6 +196,7 @@ public function add_tag(string $name, string $color, bool $enabled, array $forum $tag_id = (int) $this->db->sql_nextid(); $this->replace_forums($tag_id, $forum_ids); $this->db->sql_transaction('commit'); + $this->invalidate_catalog(); return $this->get_tag($tag_id); } @@ -229,6 +232,7 @@ public function update_tag(int $tag_id, string $name, string $color, bool $enabl $this->db->sql_query($sql); $this->replace_forums($tag_id, $forum_ids); $this->db->sql_transaction('commit'); + $this->invalidate_catalog(); return $this->get_tag($tag_id); } @@ -251,6 +255,7 @@ public function set_enabled(int $tag_id, bool $enabled): bool SET prefix_enabled = ' . (int) (bool) $enabled . ' WHERE prefix_id = ' . (int) $tag_id; $this->db->sql_query($sql); + $this->invalidate_catalog(); return true; } @@ -274,6 +279,7 @@ public function delete_tag(int $tag_id): bool $this->db->sql_query('DELETE FROM ' . $this->forums_map_table . ' WHERE prefix_id = ' . $tag_id); $this->db->sql_query('DELETE FROM ' . $this->tags_table . ' WHERE prefix_id = ' . $tag_id); $this->db->sql_transaction('commit'); + $this->invalidate_catalog(); return true; } @@ -291,6 +297,7 @@ public function delete_forum_availability(array $forum_ids): void { $this->db->sql_query('DELETE FROM ' . $this->forums_map_table . ' WHERE ' . $this->db->sql_in_set('forum_id', $forum_ids)); + $this->invalidate_catalog(); } } @@ -345,33 +352,11 @@ public function move_tag(int $tag_id, string $direction): bool WHERE ' . $this->db->sql_in_set('prefix_id', [$current_id, $target_id]); $this->db->sql_query($sql); $this->db->sql_transaction('commit'); + $this->invalidate_catalog(); return true; } - /** - * Get forum identifiers assigned to one tag. - * - * @param int $tag_id Tag identifier - * @return array Forum identifiers - */ - public function get_forum_ids(int $tag_id): array - { - $sql = 'SELECT forum_id - FROM ' . $this->forums_map_table . ' - WHERE prefix_id = ' . (int) $tag_id . ' - ORDER BY forum_id ASC'; - $result = $this->db->sql_query($sql); - $forum_ids = []; - while ($row = $this->db->sql_fetchrow($result)) - { - $forum_ids[] = (int) $row['forum_id']; - } - $this->db->sql_freeresult($result); - - return $forum_ids; - } - /** * Get forum names grouped by tag. * @@ -415,11 +400,72 @@ public function normalize_color(string $color): string */ protected function tag_exists(int $tag_id): bool { - $sql = 'SELECT prefix_id FROM ' . $this->tags_table . ' WHERE prefix_id = ' . (int) $tag_id; + return isset($this->get_catalog()['tags'][(int) $tag_id]); + } + + /** + * Load tag definitions and forum availability with one cacheable query. + * + * @return array Tag catalog + */ + protected function get_catalog(): array + { + if ($this->catalog !== null) + { + return $this->catalog; + } + + if ($this->cache && ($catalog = $this->cache->get(self::CACHE_KEY)) !== false) + { + return $this->catalog = $catalog; + } + + $catalog = [ + 'tags' => [], + 'tag_forums' => [], + 'forum_tags' => [], + ]; + $sql = 'SELECT p.*, pf.forum_id + FROM ' . $this->tags_table . ' p + LEFT JOIN ' . $this->forums_map_table . ' pf + ON pf.prefix_id = p.prefix_id + ORDER BY p.prefix_order ASC, p.prefix_id ASC, pf.forum_id ASC'; $result = $this->db->sql_query($sql); - $exists = $this->db->sql_fetchfield('prefix_id') !== false; + while ($row = $this->db->sql_fetchrow($result)) + { + $tag_id = (int) $row['prefix_id']; + $forum_id = (int) $row['forum_id']; + unset($row['forum_id']); + $catalog['tags'][$tag_id] = $row; + if ($forum_id) + { + $catalog['tag_forums'][$tag_id][] = $forum_id; + $catalog['forum_tags'][$forum_id][$tag_id] = true; + } + } $this->db->sql_freeresult($result); - return $exists; + + $this->catalog = $catalog; + if ($this->cache) + { + $this->cache->put(self::CACHE_KEY, $catalog); + } + + return $catalog; + } + + /** + * Invalidate persistent and request-local tag metadata. + * + * @return void + */ + protected function invalidate_catalog(): void + { + $this->catalog = null; + if ($this->cache) + { + $this->cache->destroy(self::CACHE_KEY); + } } /** diff --git a/tags/renderer.php b/tags/renderer.php index a5d1ff6..5baf591 100644 --- a/tags/renderer.php +++ b/tags/renderer.php @@ -21,6 +21,9 @@ class renderer /** @var string PHP extension */ protected $php_ext; + /** @var array Request-local foreground colors keyed by background */ + protected $contrast_colors = []; + /** * Constructor. * @@ -46,17 +49,18 @@ public function __construct($root_path, $php_ext) public function render(array $tags, int $forum_id = 0, array $selected_ids = [], array $url_params = [], bool $toggle = false): array { $selected_ids = array_values(array_unique(array_map('intval', $selected_ids))); + $selected = array_fill_keys($selected_ids, true); $rendered = []; foreach ($tags as $tag) { $tag_id = (int) $tag['prefix_id']; - $is_selected = in_array($tag_id, $selected_ids, true); + $is_selected = isset($selected[$tag_id]); $link_ids = $selected_ids; if ($toggle && $is_selected) { $link_ids = array_values(array_diff($link_ids, [$tag_id])); } - else if (!in_array($tag_id, $link_ids, true)) + else if (!isset($selected[$tag_id])) { $link_ids[] = $tag_id; } @@ -82,7 +86,11 @@ public function render(array $tags, int $forum_id = 0, array $selected_ids = [], */ public function contrast_color(string $hex): string { - $hex = ltrim($hex, '#'); + $hex = strtoupper(ltrim($hex, '#')); + if (isset($this->contrast_colors[$hex])) + { + return $this->contrast_colors[$hex]; + } $channels = [ hexdec(substr($hex, 0, 2)) / 255, hexdec(substr($hex, 2, 2)) / 255, @@ -96,7 +104,7 @@ public function contrast_color(string $hex): string $white_contrast = 1.05 / ($luminance + 0.05); $black_contrast = ($luminance + 0.05) / 0.05; - return $white_contrast >= $black_contrast ? '#FFFFFF' : '#000000'; + return $this->contrast_colors[$hex] = $white_contrast >= $black_contrast ? '#FFFFFF' : '#000000'; } /** diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index fa51772..831ac82 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -40,7 +40,9 @@ public function test_batch_retrieval() self::assertSame(array(1, 2), array_keys($tags[10])); self::assertSame(array(1), array_keys($tags[11])); self::assertArrayNotHasKey(13, $tags); + $after_definitions = $this->db->sql_num_queries(); self::assertSame([10 => [1, 2], 11 => [1]], $manager->get_topic_tag_ids_for_topics([10, 11, 13])); + self::assertSame($after_definitions, $this->db->sql_num_queries()); } /** @@ -52,7 +54,7 @@ public function test_topic_move_preserves_assignments(): void $manager = $this->create_assignment_manager(); self::assertSame(array(2), $manager->get_topic_tag_ids(12)); - self::assertSame(array(2), array_keys($manager->get_tags_for_forum(3))); + self::assertSame(array(2), $manager->get_tag_ids_for_forum(3)); } /** @@ -69,7 +71,6 @@ public function test_empty_identifiers_are_rejected(): void self::assertSame([1], $manager->get_topic_tag_ids(13)); self::assertSame([], $manager->get_tags_for_topics([0, 0])); self::assertSame([], $manager->get_topic_tag_ids_for_topics([0, 0])); - self::assertSame([], $manager->get_effective_topic_ids([0, 0])); self::assertSame([], $manager->get_topic_tag_ids(999)); } @@ -87,6 +88,33 @@ public function test_copy_and_add_topic_tags(): void self::assertFalse($manager->copy_topic_tags(999, 13)); } + /** + * Test bulk copying to phpBB-created topics. + */ + public function test_copy_tags_to_new_topics_is_batched(): void + { + $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_topics WHERE ' . $this->db->sql_in_set('topic_id', [12, 13])); + $manager = $this->create_assignment_manager(); + + $manager->copy_tags_to_new_topics([10 => 12, 11 => 13]); + + self::assertSame([1, 2], $manager->get_topic_tag_ids(12)); + self::assertSame([1], $manager->get_topic_tag_ids(13)); + } + + /** + * Test trusted lifecycle assignment helpers. + */ + public function test_validated_assignment_helpers(): void + { + $manager = $this->create_assignment_manager(); + + self::assertTrue($manager->set_validated_topic_tags(13, [1])); + self::assertTrue($manager->add_validated_topic_tags(13, [2])); + self::assertSame([1, 2], $manager->get_topic_tag_ids(13)); + self::assertFalse($manager->set_validated_topic_tags(0, [1])); + } + /** * Test deletion helpers remove only relationships in scope. */ @@ -105,13 +133,29 @@ public function test_delete_assignment_helpers(): void /** * Test shadow topics resolve to destination assignments. */ - public function test_effective_topic_ids_resolve_shadows(): void + public function test_displayed_topic_tags_resolve_shadows(): void { $this->db->sql_query('UPDATE phpbb_topics SET forum_id = 3, topic_moved_id = 10 WHERE topic_id = 13'); $manager = $this->create_assignment_manager(); - self::assertSame([10 => 10, 13 => 10], $manager->get_effective_topic_ids([10, 13])); - self::assertSame([1, 2], array_keys($manager->get_tags_for_forum(3))); + self::assertSame([1, 2], array_keys($manager->get_tags_for_displayed_topics([13])[13])); + self::assertSame([1, 2], $manager->get_tag_ids_for_forum(3)); + self::assertSame([2], $manager->get_tag_ids_for_forum(3, [2])); + } + + /** + * Reapplying unchanged assignments performs no relationship writes. + */ + public function test_unchanged_assignment_avoids_database_churn(): void + { + $manager = $this->create_assignment_manager(); + self::assertSame([1, 2], $manager->get_topic_tag_ids(10)); + $before = $this->db->sql_num_queries(); + + self::assertTrue($manager->set_topic_tags(10, [2, 1, 2])); + + // Only topic and tag validation queries; no DELETE or INSERT. + self::assertSame(2, $this->db->sql_num_queries() - $before); } /** @@ -121,6 +165,6 @@ public function test_global_topic_tags_are_available_to_forum_filters(): void { $this->db->sql_query('UPDATE phpbb_topics SET forum_id = 0, topic_type = ' . POST_GLOBAL . ' WHERE topic_id = 10'); - self::assertSame([1, 2], array_keys($this->create_assignment_manager()->get_tags_for_forum(3))); + self::assertSame([1, 2], $this->create_assignment_manager()->get_tag_ids_for_forum(3)); } } diff --git a/tests/dbal/filter_test.php b/tests/dbal/filter_test.php index dd1fea6..d83012f 100644 --- a/tests/dbal/filter_test.php +++ b/tests/dbal/filter_test.php @@ -31,6 +31,8 @@ protected function create_filter() */ public function test_single_tag_filter() { + $filter = $this->create_filter(); + self::assertStringNotContainsString('GROUP BY', $filter->condition('t', [1])); self::assertSame(array(10, 11), $this->query_ids(array(1))); } @@ -39,6 +41,7 @@ public function test_single_tag_filter() */ public function test_multiple_tags_use_and_semantics_without_duplicates() { + self::assertStringContainsString('GROUP BY', $this->create_filter()->condition('t', [1, 2])); self::assertSame(array(10), $this->query_ids(array(1, 2))); } diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index e34d942..309552e 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -57,6 +57,31 @@ public function test_available_tags_exclude_disabled_and_wrong_forum() $tags = $this->create_tag_manager()->get_available_tags(2); self::assertSame(array(1, 2), array_keys($tags)); self::assertSame(array(1, 4), array_keys($this->create_tag_manager()->get_available_tags(3))); + self::assertSame([4], $this->create_tag_manager()->get_unavailable_tag_ids(2)); + } + + /** + * Tag metadata is loaded once and shared through phpBB's cache driver. + */ + public function test_tag_catalog_is_cached(): void + { + $before = $this->db->sql_num_queries(); + self::assertSame([1, 2], array_keys($this->create_tag_manager()->get_available_tags(2))); + $after_first_read = $this->db->sql_num_queries(); + self::assertSame(1, $after_first_read - $before); + + self::assertSame([1], array_keys($this->create_tag_manager()->get_assignable_tags(2, [1, 3, 999]))); + self::assertSame($after_first_read, $this->db->sql_num_queries()); + } + + /** + * Mutations invalidate request-independent catalog data. + */ + public function test_tag_catalog_is_invalidated_after_mutation(): void + { + self::assertArrayHasKey(1, $this->create_tag_manager()->get_available_tags(2)); + self::assertTrue($this->create_tag_manager()->set_enabled(1, false)); + self::assertArrayNotHasKey(1, $this->create_tag_manager()->get_available_tags(2)); } public function test_deleted_forum_availability_is_removed() diff --git a/tests/dbal/tags_base.php b/tests/dbal/tags_base.php index f77851e..c44702e 100644 --- a/tests/dbal/tags_base.php +++ b/tests/dbal/tags_base.php @@ -15,6 +15,9 @@ abstract class tags_base extends \phpbb_database_test_case /** @var \phpbb\db\driver\driver_interface */ protected $db; + /** @var \phpbb_mock_cache */ + protected $cache; + protected static function setup_extensions() { return array('phpbb/topicprefixes'); @@ -29,6 +32,7 @@ protected function setUp(): void { parent::setUp(); $this->db = $this->new_dbal(); + $this->cache = new \phpbb_mock_cache(); } protected function create_tag_manager() @@ -38,7 +42,8 @@ protected function create_tag_manager() 'phpbb_topic_prefixes', 'phpbb_topic_prefixes_forums', 'phpbb_topic_prefixes_topics', - 'phpbb_forums' + 'phpbb_forums', + $this->cache ); } diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php index c8934ef..71f2f9e 100644 --- a/tests/event/display_listener_test.php +++ b/tests/event/display_listener_test.php @@ -168,13 +168,9 @@ public function test_mcp_tags_are_batch_loaded_and_rendered(): void $rendered = [['TAG_ID' => 1, 'TAG_NAME' => 'Bug']]; $assignments->expects(self::once()) - ->method('get_effective_topic_ids') + ->method('get_tags_for_displayed_topics') ->with([42, 43]) - ->willReturn([42 => 99, 43 => 43]); - $assignments->expects(self::once()) - ->method('get_tags_for_topics') - ->with([99, 43]) - ->willReturn([99 => [1 => $tag]]); + ->willReturn([42 => [1 => $tag]]); $renderer->expects(self::once()) ->method('render') ->with([1 => $tag], 2) diff --git a/tests/event/lifecycle_listener_test.php b/tests/event/lifecycle_listener_test.php index 1eaf5f4..b3a11e4 100644 --- a/tests/event/lifecycle_listener_test.php +++ b/tests/event/lifecycle_listener_test.php @@ -46,12 +46,13 @@ public function test_topic_and_forum_deletion_cleanup(): void public function test_split_fork_and_relocated_topics_copy_tags(): void { $assignments = $this->assignment_mock(); - $assignments->expects(self::exactly(4))->method('copy_topic_tags')->withConsecutive( - [10, 20], - [10, 30], - [10, 40], - [11, 41] - )->willReturn(true); + $assignments->expects(self::exactly(3)) + ->method('copy_tags_to_new_topics') + ->withConsecutive( + [[10 => 20]], + [[10 => 30]], + [[10 => 40, 11 => 41]] + ); $listener = $this->listener($assignments, $this->manager_mock()); $listener->copy_split_tags(new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20])); @@ -67,7 +68,7 @@ public function test_complete_topic_merge_unions_deleted_source_tags(): void { $assignments = $this->assignment_mock(); $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); - $assignments->expects(self::once())->method('add_topic_tags')->with(20, [2])->willReturn(true); + $assignments->expects(self::once())->method('add_validated_topic_tags')->with(20, [2])->willReturn(true); $listener = $this->listener($assignments, $this->manager_mock()); $listener->delete_topic_relationships(new \phpbb\event\data([ @@ -84,8 +85,8 @@ public function test_split_all_uses_tags_captured_before_source_deletion(): void { $assignments = $this->assignment_mock(); $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); - $assignments->expects(self::once())->method('set_topic_tags')->with(20, [2])->willReturn(true); - $assignments->expects(self::never())->method('copy_topic_tags'); + $assignments->expects(self::once())->method('set_validated_topic_tags')->with(20, [2])->willReturn(true); + $assignments->expects(self::never())->method('copy_tags_to_new_topics'); $listener = $this->listener($assignments, $this->manager_mock()); $listener->delete_topic_relationships(new \phpbb\event\data([ @@ -100,7 +101,7 @@ public function test_post_merge_only_unions_tags_when_source_was_removed(): void $assignments = $this->assignment_mock(); $assignments->expects(self::exactly(2))->method('topic_exists')->with(10)->willReturnOnConsecutiveCalls(true, false); $assignments->expects(self::once())->method('get_topic_tag_ids')->with(10)->willReturn([1]); - $assignments->expects(self::once())->method('add_topic_tags')->with(20, [1])->willReturn(true); + $assignments->expects(self::once())->method('add_validated_topic_tags')->with(20, [1])->willReturn(true); $listener = $this->listener($assignments, $this->manager_mock()); $event = new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20]); diff --git a/tests/event/posting_listener_test.php b/tests/event/posting_listener_test.php index 6ad9ba5..5a87c15 100644 --- a/tests/event/posting_listener_test.php +++ b/tests/event/posting_listener_test.php @@ -67,9 +67,10 @@ public function test_first_post_edit_loads_existing_assignments() $this->manager->method('get_available_tags')->willReturn($this->tags()); $this->request->method('is_set_post')->willReturn(false); $this->assignments->expects(self::once()) - ->method('get_tags_for_topics') - ->with(array(10)) - ->willReturn(array(10 => array(2 => $this->tags()[2]))); + ->method('get_topic_tag_ids') + ->with(10) + ->willReturn(array(2)); + $this->manager->method('get_tags_by_ids')->with(array(2))->willReturn(array(2 => $this->tags()[2])); $event = new \phpbb\event\data(array( 'mode' => 'edit', 'forum_id' => 2, 'topic_id' => 10, 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'page_data' => array(), @@ -88,9 +89,10 @@ public function test_first_post_edit_preserves_moved_topic_tags(): void $this->manager->method('get_available_tags')->with(3)->willReturn(array(1 => $tags[1])); $this->request->method('is_set_post')->willReturn(false); $this->assignments->expects(self::once()) - ->method('get_tags_for_topics') - ->with(array(10)) - ->willReturn(array(10 => array(2 => $tags[2]))); + ->method('get_topic_tag_ids') + ->with(10) + ->willReturn(array(2)); + $this->manager->method('get_tags_by_ids')->with(array(2))->willReturn(array(2 => $tags[2])); $event = new \phpbb\event\data(array( 'mode' => 'edit', 'forum_id' => 3, 'topic_id' => 10, 'post_data' => array('post_id' => 100, 'topic_first_post_id' => 100), 'page_data' => array(), @@ -144,7 +146,7 @@ public function test_valid_submission_saves_relationships_without_changing_subje $this->request->method('is_set_post')->willReturn(true); $this->request->method('variable')->willReturn(array(1, 2, 2)); $this->manager->method('get_assignable_tags')->willReturn($this->tags()); - $this->assignments->expects(self::once())->method('set_topic_tags')->with(42, array(1, 2)); + $this->assignments->expects(self::once())->method('set_validated_topic_tags')->with(42, array(1, 2)); $listener = $this->listener(); $validation = new \phpbb\event\data(array( 'submit' => true, 'mode' => 'post', 'forum_id' => 2, 'post_data' => array(), 'error' => array(), @@ -170,7 +172,7 @@ public function test_capture_validates_when_needed(): void ->method('get_assignable_tags') ->with(2, array(1, 2)) ->willReturn($this->tags()); - $this->assignments->expects(self::once())->method('set_topic_tags')->with(42, array(1, 2)); + $this->assignments->expects(self::once())->method('set_validated_topic_tags')->with(42, array(1, 2)); $listener = $this->listener(); $listener->capture_submission(new \phpbb\event\data(array( @@ -186,7 +188,7 @@ public function test_non_topic_or_absent_tag_submission_is_ignored(): void { $this->request->method('is_set_post')->willReturn(false); $this->manager->expects(self::never())->method('get_assignable_tags'); - $this->assignments->expects(self::never())->method('set_topic_tags'); + $this->assignments->expects(self::never())->method('set_validated_topic_tags'); $listener = $this->listener(); $listener->validate_submission(new \phpbb\event\data(array( diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index 48f1d4b..3f27553 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -46,7 +46,9 @@ public function test_filter_modifies_ids_count_and_pagination() $manager->method('get_available_tags')->willReturnCallback(function ($forum_id, $enabled_only = true) use ($tags) { return array(1 => $tags[1]); }); - $assignments->expects(self::once())->method('get_tags_for_forum')->with(2)->willReturn(array(2 => $tags[2])); + $manager->method('get_unavailable_tag_ids')->with(2)->willReturn([2]); + $manager->method('get_tags_by_ids')->with([2])->willReturn(array(2 => $tags[2])); + $assignments->expects(self::once())->method('get_tag_ids_for_forum')->with(2, [2])->willReturn([2]); $request->method('variable')->with('tags', '')->willReturn('1,2'); $filter->expects(self::once())->method('count_topics')->with(2, array(1, 2), 0)->willReturn(7); $filter->method('condition')->willReturn('FILTER_CONDITION'); @@ -119,7 +121,9 @@ public function test_invalid_filter_is_ignored(): void $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); $manager->method('get_available_tags')->willReturn(array()); - $assignments->method('get_tags_for_forum')->willReturn(array()); + $manager->method('get_unavailable_tag_ids')->willReturn(array()); + $manager->method('get_tags_by_ids')->willReturn(array()); + $assignments->method('get_tag_ids_for_forum')->willReturn(array()); $request->method('variable')->with('tags', '')->willReturn('1,invalid'); $filter->expects(self::never())->method('count_topics'); $renderer->method('render')->willReturn(array()); From 797ba46776ee67db6627f93726e5dcb05240311c Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 11:36:13 -0700 Subject: [PATCH 11/32] Revert handling of tags when all users posts moved to a new forum --- CHANGELOG.md | 2 +- event/lifecycle_listener.php | 16 +------ tags/assignment_manager.php | 59 ------------------------- tests/dbal/assignment_manager_test.php | 14 ------ tests/event/lifecycle_listener_test.php | 20 ++++----- 5 files changed, 11 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66be47..a4eebe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - Added accessible tag selection when creating topics and editing first posts. - Added tag badges to forum, topic, and search displays. - Added multi-tag AND filtering to forum topic lists. -- Preserved tag metadata through topic moves, splits, forks, merges, and ACP post relocation. +- Preserved tag metadata through topic moves, splits, forks, and merges. - Added relationship cleanup for topic and forum deletion. - Added conservative migration of legacy definitions, assignments, titles, and first-post subjects. diff --git a/event/lifecycle_listener.php b/event/lifecycle_listener.php index 550afe0..9afce8f 100644 --- a/event/lifecycle_listener.php +++ b/event/lifecycle_listener.php @@ -61,7 +61,6 @@ public static function getSubscribedEvents(): array 'core.mcp_main_fork_sql_after' => 'copy_fork_tags', 'core.mcp_forum_merge_topics_after' => 'merge_topics_tags', 'core.mcp_topics_merge_posts_after' => 'merge_posts_tags', - 'core.acp_users_move_posts_after' => 'copy_relocated_post_tags', ]; } @@ -130,7 +129,7 @@ public function copy_split_tags($event): void return; } - $this->assignments->copy_tags_to_new_topics([$source_topic_id => (int) $event['to_topic_id']]); + $this->assignments->copy_topic_tags($source_topic_id, (int) $event['to_topic_id']); } /** @@ -148,7 +147,7 @@ public function copy_fork_tags($event): void } $this->processed_forks[$new_topic_id] = true; - $this->assignments->copy_tags_to_new_topics([(int) $event['row']['topic_id'] => $new_topic_id]); + $this->assignments->copy_topic_tags((int) $event['row']['topic_id'], $new_topic_id); } /** @@ -179,17 +178,6 @@ public function merge_posts_tags($event): void } } - /** - * Copy tags when ACP user administration creates topics from relocated posts. - * - * @param \phpbb\event\data $event Event data - * @return void - */ - public function copy_relocated_post_tags($event): void - { - $this->assignments->copy_tags_to_new_topics($event['new_topic_id_map']); - } - /** * Add captured source tags to a merge destination. * diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index ebd5087..dd6ba3a 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -184,65 +184,6 @@ public function copy_topic_tags(int $source_topic_id, int $target_topic_id): boo return $this->set_topic_tags($target_topic_id, $this->get_topic_tag_ids($source_topic_id)); } - /** - * Batch-copy relationships to topics newly created by phpBB. - * - * Target topics must not already have tag relationships. Source relationships - * already guarantee valid tag identifiers, so no per-topic validation is run. - * - * @param array $topic_id_map Target topic IDs keyed by source topic ID - * @return void - */ - public function copy_tags_to_new_topics(array $topic_id_map): void - { - $map = []; - foreach ($topic_id_map as $source_topic_id => $target_topic_id) - { - $source_topic_id = (int) $source_topic_id; - $target_topic_id = (int) $target_topic_id; - if ($source_topic_id && $target_topic_id && $source_topic_id !== $target_topic_id) - { - $map[$source_topic_id] = $target_topic_id; - } - } - if (!$map) - { - return; - } - - $source_tags = $this->get_topic_tag_ids_for_topics(array_keys($map)); - $target_tags = []; - foreach ($map as $source_topic_id => $target_topic_id) - { - foreach ($source_tags[$source_topic_id] ?? [] as $tag_id) - { - $target_tags[$target_topic_id][$tag_id] = true; - } - } - - $rows = []; - foreach ($target_tags as $target_topic_id => $tag_ids) - { - foreach (array_keys($tag_ids) as $tag_id) - { - $rows[] = ['topic_id' => $target_topic_id, 'prefix_id' => $tag_id]; - } - } - if ($rows) - { - $this->db->sql_multi_insert($this->topic_map_table, $rows); - } - - foreach ($map as $target_topic_id) - { - $tag_ids = array_keys($target_tags[$target_topic_id] ?? []); - sort($tag_ids, SORT_NUMERIC); - $this->topic_tag_ids[$target_topic_id] = $tag_ids; - $this->topic_tag_ids_loaded[$target_topic_id] = true; - unset($this->topic_tags[$target_topic_id], $this->topic_tags_loaded[$target_topic_id]); - } - } - /** * Delete assignments belonging to topics. * diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 831ac82..6d6dc62 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -88,20 +88,6 @@ public function test_copy_and_add_topic_tags(): void self::assertFalse($manager->copy_topic_tags(999, 13)); } - /** - * Test bulk copying to phpBB-created topics. - */ - public function test_copy_tags_to_new_topics_is_batched(): void - { - $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_topics WHERE ' . $this->db->sql_in_set('topic_id', [12, 13])); - $manager = $this->create_assignment_manager(); - - $manager->copy_tags_to_new_topics([10 => 12, 11 => 13]); - - self::assertSame([1, 2], $manager->get_topic_tag_ids(12)); - self::assertSame([1], $manager->get_topic_tag_ids(13)); - } - /** * Test trusted lifecycle assignment helpers. */ diff --git a/tests/event/lifecycle_listener_test.php b/tests/event/lifecycle_listener_test.php index b3a11e4..b59e1c0 100644 --- a/tests/event/lifecycle_listener_test.php +++ b/tests/event/lifecycle_listener_test.php @@ -22,7 +22,6 @@ public function test_subscribed_events(): void 'core.mcp_main_fork_sql_after', 'core.mcp_forum_merge_topics_after', 'core.mcp_topics_merge_posts_after', - 'core.acp_users_move_posts_after', ], array_keys(\phpbb\topicprefixes\event\lifecycle_listener::getSubscribedEvents())); } @@ -43,25 +42,22 @@ public function test_topic_and_forum_deletion_cleanup(): void $listener->delete_moved_forum_availability(new \phpbb\event\data(['from_id' => 3])); } - public function test_split_fork_and_relocated_topics_copy_tags(): void + public function test_split_and_fork_topics_copy_tags(): void { $assignments = $this->assignment_mock(); - $assignments->expects(self::exactly(3)) - ->method('copy_tags_to_new_topics') + $assignments->expects(self::exactly(2)) + ->method('copy_topic_tags') ->withConsecutive( - [[10 => 20]], - [[10 => 30]], - [[10 => 40, 11 => 41]] - ); + [10, 20], + [10, 30] + ) + ->willReturn(true); $listener = $this->listener($assignments, $this->manager_mock()); $listener->copy_split_tags(new \phpbb\event\data(['topic_id' => 10, 'to_topic_id' => 20])); $fork = new \phpbb\event\data(['new_topic_id' => 30, 'row' => ['topic_id' => 10]]); $listener->copy_fork_tags($fork); $listener->copy_fork_tags($fork); - $listener->copy_relocated_post_tags(new \phpbb\event\data([ - 'new_topic_id_map' => [10 => 40, 11 => 41], - ])); } public function test_complete_topic_merge_unions_deleted_source_tags(): void @@ -86,7 +82,7 @@ public function test_split_all_uses_tags_captured_before_source_deletion(): void $assignments = $this->assignment_mock(); $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); $assignments->expects(self::once())->method('set_validated_topic_tags')->with(20, [2])->willReturn(true); - $assignments->expects(self::never())->method('copy_tags_to_new_topics'); + $assignments->expects(self::never())->method('copy_topic_tags'); $listener = $this->listener($assignments, $this->manager_mock()); $listener->delete_topic_relationships(new \phpbb\event\data([ From ded6748b1d7053125a37c05e615795cf33365e21 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 11:36:20 -0700 Subject: [PATCH 12/32] CSS improvements --- styles/all/theme/topic_tags.css | 1 + 1 file changed, 1 insertion(+) diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css index 3c81bf2..705a7ff 100644 --- a/styles/all/theme/topic_tags.css +++ b/styles/all/theme/topic_tags.css @@ -14,6 +14,7 @@ line-height: 1.35; padding: 1px 6px; text-decoration: none; + margin: 1px; } a.topic-tag:hover, From db7c63ccfd5906a5a5d083710337df7fa8b447a3 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 11:39:34 -0700 Subject: [PATCH 13/32] Avoid no-op filter links for global topics --- styles/all/template/topic_tag_badges.html | 4 ++++ tests/tags/renderer_test.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/styles/all/template/topic_tag_badges.html b/styles/all/template/topic_tag_badges.html index 21bc7b0..c9f99ba 100644 --- a/styles/all/template/topic_tag_badges.html +++ b/styles/all/template/topic_tag_badges.html @@ -1,7 +1,11 @@ {% if tags %} {% for tag in tags %} + {% if tag.U_FILTER %} {{ tag.TAG_NAME }} + {% else %} + {{ tag.TAG_NAME }} + {% endif %} {% endfor %} {% endif %} diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php index 51d27c8..c436126 100644 --- a/tests/tags/renderer_test.php +++ b/tests/tags/renderer_test.php @@ -71,4 +71,21 @@ public function test_rendered_filter_tags_toggle_selected_ids(): void self::assertStringContainsString('tags=2', $rendered[0]['U_FILTER']); self::assertSame('#FFFFFF', $rendered[0]['TAG_TEXT_COLOR']); } + + /** + * Test tags without a forum context do not receive filter URLs. + */ + public function test_rendered_tags_without_forum_are_not_filterable(): void + { + $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); + $tags = [[ + 'prefix_id' => 1, + 'prefix_tag' => 'Global', + 'prefix_color' => 'D4351C', + ]]; + + $rendered = $renderer->render($tags); + + self::assertSame('', $rendered[0]['U_FILTER']); + } } From 3c4ef93b4be2aeefed1952c3fbba20b5d400b019 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 13:51:16 -0700 Subject: [PATCH 14/32] Small code fixes and improvements --- composer.json | 4 ++-- event/lifecycle_listener.php | 8 ++++++-- event/viewforum_listener.php | 2 +- ext.php | 6 +++--- language/en/acp_topic_prefixes.php | 4 ---- migrations/v200_data.php | 8 +++++--- styles/all/theme/topic_tags.css | 9 +++------ tags/manager.php | 4 ++-- tags/renderer.php | 4 ++-- tests/event/lifecycle_listener_test.php | 8 ++++---- 10 files changed, 28 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index a47e71a..867d481 100644 --- a/composer.json +++ b/composer.json @@ -13,12 +13,12 @@ }, { "name": "Ruslan Uzdenov", - "homepage": "http://www.phpbbguru.net", + "homepage": "https://www.phpbbguru.net", "role": "Extensions Development Team" } ], "require": { - "php": ">=7.1", + "php": ">=7.2", "composer/installers": "^1.0 || ^2.0" }, "require-dev": { diff --git a/event/lifecycle_listener.php b/event/lifecycle_listener.php index 9afce8f..c5748b9 100644 --- a/event/lifecycle_listener.php +++ b/event/lifecycle_listener.php @@ -191,13 +191,17 @@ protected function merge_deleted_tags(array $source_topic_ids, int $target_topic foreach ($source_topic_ids as $source_topic_id) { $source_topic_id = (int) $source_topic_id; - $tag_ids = array_merge($tag_ids, $this->deleted_topic_tags[$source_topic_id] ?? $this->assignments->get_topic_tag_ids($source_topic_id)); + $source_tag_ids = $this->deleted_topic_tags[$source_topic_id] ?? $this->assignments->get_topic_tag_ids($source_topic_id); + foreach ($source_tag_ids as $tag_id) + { + $tag_ids[(int) $tag_id] = true; + } unset($this->deleted_topic_tags[$source_topic_id]); } if ($tag_ids) { - $this->assignments->add_validated_topic_tags($target_topic_id, $tag_ids); + $this->assignments->add_validated_topic_tags($target_topic_id, array_keys($tag_ids)); } } } diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index 84a71fe..26d86c3 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -109,7 +109,7 @@ public function configure_filter($event): void $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); $forum_tags = $this->manager->get_available_tags($this->forum_id, false); - $available = array_filter($forum_tags, function ($tag) { + $available = array_filter($forum_tags, static function ($tag) { return !empty($tag['prefix_enabled']); }); $unavailable_ids = $this->manager->get_unavailable_tag_ids($this->forum_id); diff --git a/ext.php b/ext.php index f617c39..3e838ad 100644 --- a/ext.php +++ b/ext.php @@ -12,7 +12,7 @@ /** * This ext class is optional and can be omitted if left empty. - * However you can add special (un)installation commands in the + * However, you can add special (un)installation commands in the * methods enable_step(), disable_step() and purge_step(). As it is, * these methods are defined in \phpbb\extension\base, which this * class extends, but you can overwrite them to give special @@ -25,12 +25,12 @@ class ext extends \phpbb\extension\base * The current phpBB version should meet or exceed * the minimum version required by this extension: * - * Requires phpBB 3.3.18 and PHP 7.1 + * Requires phpBB 3.3.18 and PHP 7.2 * * @return bool */ public function is_enableable() { - return phpbb_version_compare(PHPBB_VERSION, '3.3.18-dev', '>=') && PHP_VERSION_ID >= 70100; + return phpbb_version_compare(PHPBB_VERSION, '3.3.18-dev', '>=') && PHP_VERSION_ID >= 70200; } } diff --git a/language/en/acp_topic_prefixes.php b/language/en/acp_topic_prefixes.php index 71eb4d9..9848741 100644 --- a/language/en/acp_topic_prefixes.php +++ b/language/en/acp_topic_prefixes.php @@ -39,8 +39,4 @@ 'TOPIC_TAG_NAME_REQUIRED' => 'Tag text is required.', 'TOPIC_TAG_COLOR_INVALID' => 'Badge color must be a six-digit hexadecimal color.', 'TOPIC_TAG_NOT_FOUND' => 'Requested topic tag does not exist.', - - // Legacy keys remain for migration/module lineage and third-party language packs. - 'TOPIC_PREFIXES' => 'Topic tags', - 'TOPIC_PREFIXES_EXPLAIN' => 'Manage administrator-curated topic tags.', ]); diff --git a/migrations/v200_data.php b/migrations/v200_data.php index 811648a..4cd252b 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -15,9 +15,11 @@ */ class v200_data extends \phpbb\db\migration\migration { - const DEFAULT_COLOR = '4A76A8'; - const BATCH_SIZE = 500; - const UPDATE_CASE_BATCH_SIZE = 10; + protected const DEFAULT_COLOR = '4A76A8'; + protected const BATCH_SIZE = 500; + + // sql_case() produces nested CASE expressions; SQL Server supports at most 10 levels. + protected const UPDATE_CASE_BATCH_SIZE = 10; /** * {@inheritdoc} diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css index 705a7ff..118edab 100644 --- a/styles/all/theme/topic_tags.css +++ b/styles/all/theme/topic_tags.css @@ -25,8 +25,9 @@ a.topic-tag:focus, text-decoration: none; } -.topic-tag-selected { - box-shadow: 0 0 0 2px currentColor; +.topic-tag-selected, +.topic-tag-checkbox:checked + .topic-tag { + box-shadow: 0 0 0 2px #105289; } .topic-tag-choices { @@ -52,10 +53,6 @@ a.topic-tag:focus, width: 1px; } -.topic-tag-checkbox:checked + .topic-tag { - box-shadow: 0 0 0 2px currentColor; -} - .topic-tag-checkbox:checked + .topic-tag::before { content: "\2713\00a0"; } diff --git a/tags/manager.php b/tags/manager.php index 4950586..f6e4639 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -21,8 +21,8 @@ */ class manager { - const DEFAULT_COLOR = '4A76A8'; - const CACHE_KEY = '_topicprefixes_tag_catalog'; + public const DEFAULT_COLOR = '4A76A8'; + public const CACHE_KEY = '_topicprefixes_tag_catalog'; /** @var driver_interface */ protected $db; diff --git a/tags/renderer.php b/tags/renderer.php index 5baf591..61db97a 100644 --- a/tags/renderer.php +++ b/tags/renderer.php @@ -79,7 +79,7 @@ public function render(array $tags, int $forum_id = 0, array $selected_ids = [], } /** - * Select black or white text with greatest WCAG contrast. + * Select black or white text with the greatest WCAG contrast. * * @param string $hex Six-digit hexadecimal background color * @return string Hexadecimal foreground color @@ -98,7 +98,7 @@ public function contrast_color(string $hex): string ]; foreach ($channels as $key => $channel) { - $channels[$key] = $channel <= 0.03928 ? $channel / 12.92 : pow(($channel + 0.055) / 1.055, 2.4); + $channels[$key] = $channel <= 0.03928 ? $channel / 12.92 : (($channel + 0.055) / 1.055) ** 2.4; } $luminance = 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; $white_contrast = 1.05 / ($luminance + 0.05); diff --git a/tests/event/lifecycle_listener_test.php b/tests/event/lifecycle_listener_test.php index b59e1c0..6c1caee 100644 --- a/tests/event/lifecycle_listener_test.php +++ b/tests/event/lifecycle_listener_test.php @@ -63,17 +63,17 @@ public function test_split_and_fork_topics_copy_tags(): void public function test_complete_topic_merge_unions_deleted_source_tags(): void { $assignments = $this->assignment_mock(); - $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10])->willReturn([10 => [2]]); - $assignments->expects(self::once())->method('add_validated_topic_tags')->with(20, [2])->willReturn(true); + $assignments->expects(self::once())->method('get_topic_tag_ids_for_topics')->with([10, 11])->willReturn([10 => [2], 11 => [2, 3]]); + $assignments->expects(self::once())->method('add_validated_topic_tags')->with(20, [2, 3])->willReturn(true); $listener = $this->listener($assignments, $this->manager_mock()); $listener->delete_topic_relationships(new \phpbb\event\data([ - 'topic_ids' => [10], + 'topic_ids' => [10, 11], 'table_ary' => ['phpbb_topics'], ])); $listener->merge_topics_tags(new \phpbb\event\data([ 'to_topic_id' => 20, - 'all_topic_data' => [10 => [], 20 => []], + 'all_topic_data' => [10 => [], 11 => [], 20 => []], ])); } From bac63672b7afedd6930dceb397262f150b6adbf7 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 14:12:12 -0700 Subject: [PATCH 15/32] Fix tests --- tags/manager.php | 2 +- tests/controller/admin_controller_test.php | 82 ++++++++++++++++++++++ tests/dbal/assignment_manager_test.php | 1 + tests/dbal/tag_manager_test.php | 4 +- tests/tags/renderer_test.php | 1 + 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/tags/manager.php b/tags/manager.php index f6e4639..e700d4a 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -368,7 +368,7 @@ public function get_forum_names_by_tag(): array FROM ' . $this->forums_map_table . ' pf INNER JOIN ' . $this->forums_table . ' f ON f.forum_id = pf.forum_id - ORDER BY f.left_id ASC'; + ORDER BY pf.prefix_id ASC, f.left_id ASC'; $result = $this->db->sql_query($sql); $forums = []; while ($row = $this->db->sql_fetchrow($result)) diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index e25c188..198f397 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -81,6 +81,18 @@ public function test_toggle_updates_enabled_state() $this->controller->toggle_tag(1); } + public function test_ajax_toggle_sends_success_response(): void + { + $this->request->method('variable')->willReturn('valid'); + $this->request->method('is_ajax')->willReturn(true); + $this->manager->expects(self::once())->method('get_tag')->with(1)->willReturn(array('prefix_enabled' => 1)); + $this->manager->expects(self::once())->method('set_enabled')->with(1, false)->willReturn(true); + + self::assertSame('{"success":true}', $this->capture_json_response(function () { + $this->controller->toggle_tag(1); + })); + } + public function test_move_updates_order() { $this->request->method('variable')->willReturn('valid'); @@ -89,6 +101,17 @@ public function test_move_updates_order() $this->controller->move_tag(2, 'down'); } + public function test_ajax_move_sends_success_response(): void + { + $this->request->method('variable')->willReturn('valid'); + $this->request->method('is_ajax')->willReturn(true); + $this->manager->expects(self::once())->method('move_tag')->with(2, 'down')->willReturn(true); + + self::assertSame('{"success":true}', $this->capture_json_response(function () { + $this->controller->move_tag(2, 'down'); + })); + } + public function test_delete_requires_confirmation() { self::$confirmed = false; @@ -359,6 +382,65 @@ protected function configure_save_request(string $name, string $color): void )[$key] ?? $default; }); } + + /** + * Run phpBB's real JSON response without terminating PHPUnit. + * + * @param callable $callback Controller action that sends a JSON response + * @return string Captured response body + */ + protected function capture_json_response(callable $callback): string + { + global $cache, $db, $phpbb_dispatcher, $phpbb_hook; + + $previous_globals = array($cache ?? null, $db ?? null, $phpbb_dispatcher ?? null, $phpbb_hook ?? null); + $cache = $db = $phpbb_dispatcher = null; + $phpbb_hook = new class + { + public function call_hook($name) + { + return $name === 'exit_handler'; + } + + public function hook_return($name) + { + return $name === 'exit_handler'; + } + + public function hook_return_result($name) + { + return null; + } + }; + + $previous_error_handler = null; + $previous_error_handler = set_error_handler(static function ($severity, $message, $file, $line, $context = null) use (&$previous_error_handler) { + if (strpos($message, 'Cannot modify header information') === 0) + { + return true; + } + + return $previous_error_handler + ? call_user_func($previous_error_handler, $severity, $message, $file, $line, $context) + : false; + }); + $buffer_level = ob_get_level(); + ob_start(); + try + { + $callback(); + return ob_get_clean(); + } + finally + { + while (ob_get_level() > $buffer_level) + { + ob_end_clean(); + } + restore_error_handler(); + list($cache, $db, $phpbb_dispatcher, $phpbb_hook) = $previous_globals; + } + } } function add_form_key() diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 6d6dc62..7efb8d2 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -70,6 +70,7 @@ public function test_empty_identifiers_are_rejected(): void self::assertFalse($manager->set_topic_tags(13, [999])); self::assertSame([1], $manager->get_topic_tag_ids(13)); self::assertSame([], $manager->get_tags_for_topics([0, 0])); + self::assertSame([], $manager->get_tags_for_displayed_topics([0, 0])); self::assertSame([], $manager->get_topic_tag_ids_for_topics([0, 0])); self::assertSame([], $manager->get_topic_tag_ids(999)); } diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index 309552e..cd3bbf8 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -54,10 +54,12 @@ public function test_delete_cascades_availability_and_topic_assignments() public function test_available_tags_exclude_disabled_and_wrong_forum() { - $tags = $this->create_tag_manager()->get_available_tags(2); + $manager = $this->create_tag_manager(); + $tags = $manager->get_available_tags(2); self::assertSame(array(1, 2), array_keys($tags)); self::assertSame(array(1, 4), array_keys($this->create_tag_manager()->get_available_tags(3))); self::assertSame([4], $this->create_tag_manager()->get_unavailable_tag_ids(2)); + self::assertSame([1, 4], array_keys($manager->get_tags_by_ids([4, 1, 999, 1]))); } /** diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php index c436126..e507e60 100644 --- a/tests/tags/renderer_test.php +++ b/tests/tags/renderer_test.php @@ -32,6 +32,7 @@ public function test_automatic_contrast() self::assertSame('#FFFFFF', $renderer->contrast_color('000000')); self::assertSame('#000000', $renderer->contrast_color('FFFFFF')); self::assertSame('#FFFFFF', $renderer->contrast_color('1D70B8')); + self::assertSame('#FFFFFF', $renderer->contrast_color('000000')); } /** From 1f8a7eea444708dd5d05916c9206667b9ee63785 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 14:32:08 -0700 Subject: [PATCH 16/32] Styling fixes in the ACP --- adm/style/acp_topic_prefixes.css | 53 +++++++++++++++++++++++++++++++ adm/style/acp_topic_prefixes.html | 10 ++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/adm/style/acp_topic_prefixes.css b/adm/style/acp_topic_prefixes.css index 0f3faa4..21d906a 100644 --- a/adm/style/acp_topic_prefixes.css +++ b/adm/style/acp_topic_prefixes.css @@ -23,6 +23,59 @@ text-align: center; } +.topic-tag-forums { + line-height: 1.8; +} + +.topic-tag-forum { + display: inline-block; + white-space: nowrap; +} + +.topic-tag-forum-separator { + color: #6B7278; + margin-right: 0.5em; +} + +.topic-tag-color, +.topic-tag-color:hover, +.topic-tag-color:focus { + background: #FFFFFF; + border: 1px solid #AFAEAA; + border-radius: 4px; + box-sizing: border-box; + color: inherit; + cursor: pointer; + height: 24px; + margin: 2px 0; + padding: 3px; + width: 48px; +} + +.topic-tag-color:hover { + border-color: #666666; +} + +.topic-tag-color:focus { + border-color: #BC2A4D; + box-shadow: 0 0 0 1px #BC2A4D; + outline: none; +} + +.topic-tag-color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.topic-tag-color::-webkit-color-swatch { + border: 0; + border-radius: 2px; +} + +.topic-tag-color::-moz-color-swatch { + border: 0; + border-radius: 2px; +} + #forum_ids { min-width: 22em; } diff --git a/adm/style/acp_topic_prefixes.html b/adm/style/acp_topic_prefixes.html index 8d70158..7a769c4 100644 --- a/adm/style/acp_topic_prefixes.html +++ b/adm/style/acp_topic_prefixes.html @@ -9,7 +9,7 @@

{{ lang('TOPIC_TAGS') }}

{{ lang('TOPIC_TAG') }} - {{ lang('TOPIC_TAG_FORUMS') }} + {{ lang('TOPIC_TAG_FORUMS') }} {{ lang('TOPIC_TAG_ENABLED') }} {{ lang('ACTION') }} @@ -20,7 +20,11 @@

{{ lang('TOPIC_TAGS') }}

{{ tag.TAG_NAME }} {% if tag.FORUM_NAMES %} - {% for forum_name in tag.FORUM_NAMES %}{{ forum_name }}{% if not loop.last %}
{% endif %}{% endfor %} + + {% for forum_name in tag.FORUM_NAMES %} + {{ forum_name }}{% if not loop.last %}{% endif %} + {% endfor %} + {% else %} {{ lang('TOPIC_TAG_NO_FORUMS') }} {% endif %} @@ -54,7 +58,7 @@

{{ lang('TOPIC_TAGS') }}


{{ lang('TOPIC_TAG_COLOR_EXPLAIN') }}
-
+
From a3f5ea35db98a4615c9f8e71d2a68d1cd6a205b4 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 16:41:55 -0700 Subject: [PATCH 17/32] Prevent disabled tags from appearing as filter choices --- event/viewforum_listener.php | 2 +- tests/event/viewforum_listener_test.php | 72 ++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/event/viewforum_listener.php b/event/viewforum_listener.php index 26d86c3..234303f 100644 --- a/event/viewforum_listener.php +++ b/event/viewforum_listener.php @@ -122,7 +122,7 @@ public function configure_filter($event): void // Include tags preserved on topics moved from another forum. foreach ($assigned as $tag_id => $tag) { - if (!isset($forum_tags[$tag_id])) + if (!isset($forum_tags[$tag_id]) && !empty($tag['prefix_enabled'])) { $available[$tag_id] = $tag; } diff --git a/tests/event/viewforum_listener_test.php b/tests/event/viewforum_listener_test.php index 3f27553..1c8da45 100644 --- a/tests/event/viewforum_listener_test.php +++ b/tests/event/viewforum_listener_test.php @@ -40,8 +40,8 @@ public function test_filter_modifies_ids_count_and_pagination() $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); $tags = array( - 1 => array('prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_order' => 1), - 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8', 'prefix_order' => 2), + 1 => array('prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_enabled' => 1, 'prefix_order' => 1), + 2 => array('prefix_id' => 2, 'prefix_tag' => 'PHP 8.4', 'prefix_color' => '1D70B8', 'prefix_enabled' => 1, 'prefix_order' => 2), ); $manager->method('get_available_tags')->willReturnCallback(function ($forum_id, $enabled_only = true) use ($tags) { return array(1 => $tags[1]); @@ -107,6 +107,74 @@ public function test_filter_modifies_ids_count_and_pagination() self::assertSame('./viewforum.php?f=2&tags=1,2', $existing['base_url']); } + /** + * Provide preserved-tag filter visibility cases. + * + * @return array Requested filters, visible choices, and active filters + */ + public function preserved_tag_visibility_data(): array + { + return array( + array('', array(1, 2), array()), + array('3', array(1, 2, 3), array(3)), + ); + } + + /** + * Disabled preserved tags stay hidden unless selected. + * + * @dataProvider preserved_tag_visibility_data + */ + public function test_preserved_filter_visibility(string $requested, array $visible_ids, array $selected_ids): void + { + $manager = $this->getMockBuilder('\phpbb\topicprefixes\tags\manager')->disableOriginalConstructor()->getMock(); + $assignments = $this->getMockBuilder('\phpbb\topicprefixes\tags\assignment_manager')->disableOriginalConstructor()->getMock(); + $filter = $this->getMockBuilder('\phpbb\topicprefixes\tags\filter')->disableOriginalConstructor()->getMock(); + $renderer = $this->getMockBuilder('\phpbb\topicprefixes\tags\renderer')->disableOriginalConstructor()->getMock(); + $request = $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(); + $template = $this->getMockBuilder('\phpbb\template\template')->disableOriginalConstructor()->getMock(); + $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); + $forum_tags = array( + 1 => array('prefix_id' => 1, 'prefix_tag' => 'Forum tag', 'prefix_color' => 'D4351C', 'prefix_enabled' => 1, 'prefix_order' => 1), + 4 => array('prefix_id' => 4, 'prefix_tag' => 'Disabled forum tag', 'prefix_color' => '505A5F', 'prefix_enabled' => 0, 'prefix_order' => 4), + ); + $preserved_tags = array( + 2 => array('prefix_id' => 2, 'prefix_tag' => 'Preserved tag', 'prefix_color' => '1D70B8', 'prefix_enabled' => 1, 'prefix_order' => 2), + 3 => array('prefix_id' => 3, 'prefix_tag' => 'Disabled preserved tag', 'prefix_color' => '505A5F', 'prefix_enabled' => 0, 'prefix_order' => 3), + ); + + $manager->method('get_available_tags')->with(2, false)->willReturn($forum_tags); + $manager->method('get_unavailable_tag_ids')->with(2)->willReturn(array(2, 3)); + $manager->method('get_tags_by_ids')->with(array(2, 3))->willReturn($preserved_tags); + $assignments->method('get_tag_ids_for_forum')->with(2, array(2, 3))->willReturn(array(2, 3)); + $request->method('variable')->with('tags', '')->willReturn($requested); + $renderer->expects(self::once())->method('render')->with( + self::callback(function ($tags) use ($visible_ids) { + return array_keys($tags) === $visible_ids; + }), + 2, + $selected_ids, + array('st' => 0, 'sk' => 't', 'sd' => 'd'), + true + )->willReturn(array()); + $renderer->method('filter_url')->willReturn('./viewforum.php?f=2'); + if ($selected_ids) + { + $filter->expects(self::once())->method('count_topics')->with(2, $selected_ids, 0)->willReturn(1); + } + else + { + $filter->expects(self::never())->method('count_topics'); + } + + $listener = new \phpbb\topicprefixes\event\viewforum_listener( + $manager, $assignments, $filter, $renderer, $request, $template, $language + ); + $listener->configure_filter(new \phpbb\event\data(array( + 'forum_id' => 2, 'topics_count' => 20, 'sort_days' => 0, 'sort_key' => 't', 'sort_dir' => 'd', + ))); + } + /** * Test invalid request values leave phpBB queries unchanged. */ From 30280e6c3238881e5cac909059c199b27ddc36c0 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 20:53:33 -0700 Subject: [PATCH 18/32] =?UTF-8?q?Add=20subtle=20depth=20effect=20to=20tags?= =?UTF-8?q?=20so=20they=E2=80=99r=20enot=20so=20flat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adm/style/acp_topic_prefixes.css | 1 + styles/all/theme/topic_tags.css | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/adm/style/acp_topic_prefixes.css b/adm/style/acp_topic_prefixes.css index 21d906a..491fa9d 100644 --- a/adm/style/acp_topic_prefixes.css +++ b/adm/style/acp_topic_prefixes.css @@ -12,6 +12,7 @@ } .topic-tag { + background-image: linear-gradient(to top, transparent, rgba(255, 255, 255, 0.35)); border-radius: 3px; display: inline-block; font-weight: 600; diff --git a/styles/all/theme/topic_tags.css b/styles/all/theme/topic_tags.css index 118edab..8326c1d 100644 --- a/styles/all/theme/topic_tags.css +++ b/styles/all/theme/topic_tags.css @@ -5,14 +5,14 @@ } .topic-tag { - border: 2px solid transparent; + background-image: linear-gradient(to top, transparent, rgba(255, 255, 255, 0.35)); border-radius: 3px; box-sizing: border-box; display: inline-block; font-size: 0.85em; font-weight: 600; line-height: 1.35; - padding: 1px 6px; + padding: 3px 8px; text-decoration: none; margin: 1px; } From d4dc4c106d39eebae6a8c16558f7343e5f845b08 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 20 Sep 2026 21:09:15 -0700 Subject: [PATCH 19/32] Add tooltips to tags --- config/services.yml | 1 + event/display_listener.php | 32 +++++++++++++++++-- language/en/topic_prefixes.php | 3 ++ .../event/posting_editor_subject_before.html | 2 +- .../viewforum_body_topic_row_before.html | 3 +- styles/all/template/topic_tag_badges.html | 2 +- tests/event/display_listener_test.php | 20 +++++++++--- tests/functional/functional_test.php | 13 ++++++-- 8 files changed, 63 insertions(+), 13 deletions(-) diff --git a/config/services.yml b/config/services.yml index 41bb36d..e04e4ea 100644 --- a/config/services.yml +++ b/config/services.yml @@ -32,6 +32,7 @@ services: - '@phpbb.topicprefixes.tags.assignment_manager' - '@phpbb.topicprefixes.tags.renderer' - '@template' + - '@language' tags: - { name: event.listener } diff --git a/event/display_listener.php b/event/display_listener.php index de0339d..484d6b2 100644 --- a/event/display_listener.php +++ b/event/display_listener.php @@ -10,9 +10,10 @@ namespace phpbb\topicprefixes\event; +use phpbb\language\language; +use phpbb\template\template; use phpbb\topicprefixes\tags\assignment_manager; use phpbb\topicprefixes\tags\renderer; -use phpbb\template\template; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -29,6 +30,12 @@ class display_listener implements EventSubscriberInterface /** @var template Template object */ protected $template; + /** @var language Language object */ + protected $language; + + /** @var bool Whether extension language has been loaded */ + protected $language_loaded = false; + /** @var array Tags grouped by search result topic */ protected $search_tags = []; @@ -44,12 +51,14 @@ class display_listener implements EventSubscriberInterface * @param assignment_manager $assignments Topic/tag assignment manager * @param renderer $renderer Topic tag renderer * @param template $template Template object + * @param language $language Language object */ - public function __construct(assignment_manager $assignments, renderer $renderer, template $template) + public function __construct(assignment_manager $assignments, renderer $renderer, template $template, language $language) { $this->assignments = $assignments; $this->renderer = $renderer; $this->template = $template; + $this->language = $language; } /** @@ -78,6 +87,7 @@ public static function getSubscribedEvents(): array */ public function add_viewtopic_tags($event): void { + $this->load_language(); $topic_id = (int) $event['topic_id']; $tags = $this->assignments->get_tags_for_topics([$topic_id]); $topic_tags = $tags[$topic_id] ?? []; @@ -111,6 +121,7 @@ public function load_search_tags($event): void */ public function add_search_tags($event): void { + $this->load_language(); $topic_id = (int) $event['row']['topic_id']; $tags = $this->search_tags[$topic_id] ?? []; $tpl = $event['tpl_ary']; @@ -137,6 +148,7 @@ public function load_mcp_tags($event): void */ public function add_mcp_tags($event): void { + $this->load_language(); $topic_id = (int) $event['row']['topic_id']; $topic_row = $event['topic_row']; $topic_row['MCP_TOPIC_TAGS'] = $this->renderer->render( @@ -165,6 +177,7 @@ public function load_ucp_tags($event): void */ public function add_ucp_front_tags($event): void { + $this->load_language(); $topic_id = (int) $event['row']['topic_id']; $topic_row = $event['topicrow']; $topic_row['TOPIC_TAGS'] = $this->renderer->render( @@ -182,6 +195,7 @@ public function add_ucp_front_tags($event): void */ public function add_ucp_topiclist_tags($event): void { + $this->load_language(); $topic_id = (int) $event['topic_id']; $template_vars = $event['template_vars']; $template_vars['TOPIC_TAGS'] = $this->renderer->render( @@ -190,4 +204,18 @@ public function add_ucp_topiclist_tags($event): void ); $event['template_vars'] = $template_vars; } + + /** + * Load tooltip language once for display contexts. + * + * @return void + */ + protected function load_language(): void + { + if (!$this->language_loaded) + { + $this->language->add_lang('topic_prefixes', 'phpbb/topicprefixes'); + $this->language_loaded = true; + } + } } diff --git a/language/en/topic_prefixes.php b/language/en/topic_prefixes.php index cf2cba4..1d584b7 100644 --- a/language/en/topic_prefixes.php +++ b/language/en/topic_prefixes.php @@ -22,5 +22,8 @@ 'TOPIC_TAGS' => 'Topic tags', 'TOPIC_TAGS_FILTER' => 'Filter by tag', 'TOPIC_TAGS_CLEAR_FILTERS' => 'Clear tag filters', + 'TOPIC_TAG_FILTER_TOOLTIP' => 'Filter topics by β€œ%s”', + 'TOPIC_TAG_FILTER_REMOVE_TOOLTIP' => 'Remove β€œ%s” from topic filters', + 'TOPIC_TAG_TOGGLE_TOOLTIP' => 'Select or deselect the β€œ%s” topic tag', 'TOPIC_TAGS_INVALID' => 'One or more selected topic tags are disabled or unavailable in this forum.', ]); diff --git a/styles/all/template/event/posting_editor_subject_before.html b/styles/all/template/event/posting_editor_subject_before.html index 68ec2e9..1e76d8e 100644 --- a/styles/all/template/event/posting_editor_subject_before.html +++ b/styles/all/template/event/posting_editor_subject_before.html @@ -6,7 +6,7 @@ {{ lang('TOPIC_TAGS') }} {% for tag in TOPIC_TAGS %} - + {% endfor %} diff --git a/styles/all/template/event/viewforum_body_topic_row_before.html b/styles/all/template/event/viewforum_body_topic_row_before.html index 9f8f36c..cd73457 100644 --- a/styles/all/template/event/viewforum_body_topic_row_before.html +++ b/styles/all/template/event/viewforum_body_topic_row_before.html @@ -4,7 +4,8 @@ {{ lang('TOPIC_TAGS_FILTER') ~ lang('COLON') }} {% for tag in TOPIC_TAG_FILTERS %} - {{ tag.TAG_NAME }} + {% set tooltip = lang(tag.S_SELECTED ? 'TOPIC_TAG_FILTER_REMOVE_TOOLTIP' : 'TOPIC_TAG_FILTER_TOOLTIP', tag.TAG_NAME) %} + {{ tag.TAG_NAME }} {% endfor %} {% if S_TOPIC_TAG_FILTERED %}{{ lang('TOPIC_TAGS_CLEAR_FILTERS') }}{% endif %} diff --git a/styles/all/template/topic_tag_badges.html b/styles/all/template/topic_tag_badges.html index c9f99ba..1451a3d 100644 --- a/styles/all/template/topic_tag_badges.html +++ b/styles/all/template/topic_tag_badges.html @@ -2,7 +2,7 @@ {% for tag in tags %} {% if tag.U_FILTER %} - {{ tag.TAG_NAME }} + {{ tag.TAG_NAME }} {% else %} {{ tag.TAG_NAME }} {% endif %} diff --git a/tests/event/display_listener_test.php b/tests/event/display_listener_test.php index 71f2f9e..3f20b1f 100644 --- a/tests/event/display_listener_test.php +++ b/tests/event/display_listener_test.php @@ -69,7 +69,7 @@ public function test_viewtopic_tags_use_template_block(): void $template->expects(self::never()) ->method('assign_var'); - $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener = $this->listener($assignments, $renderer, $template); $event = new \phpbb\event\data(['topic_id' => 42, 'forum_id' => 2]); $listener->add_viewtopic_tags($event); } @@ -98,7 +98,7 @@ public function test_search_tags_are_batch_loaded_and_rendered(): void ->with([1 => $tag], 2) ->willReturn($rendered); - $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener = $this->listener($assignments, $renderer, $template); $listener->load_search_tags(new \phpbb\event\data([ 'rowset' => [['topic_id' => 42], ['topic_id' => 43]], 'show_results' => 'topics', @@ -137,7 +137,7 @@ public function test_search_post_tags_are_batch_loaded_and_rendered(): void ->with([1 => $tag], 2) ->willReturn($rendered); - $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener = $this->listener($assignments, $renderer, $template); $listener->load_search_tags(new \phpbb\event\data([ 'rowset' => [['topic_id' => 42]], 'show_results' => 'posts', @@ -176,7 +176,7 @@ public function test_mcp_tags_are_batch_loaded_and_rendered(): void ->with([1 => $tag], 2) ->willReturn($rendered); - $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener = $this->listener($assignments, $renderer, $template); $listener->load_mcp_tags(new \phpbb\event\data(['topic_list' => [42, 43]])); $event = new \phpbb\event\data([ 'row' => ['topic_id' => 42, 'forum_id' => 2], @@ -211,7 +211,7 @@ public function test_ucp_tags_are_batch_loaded_and_rendered(): void ->with([1 => $tag], 2) ->willReturn($rendered); - $listener = new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template); + $listener = $this->listener($assignments, $renderer, $template); $listener->load_ucp_tags(new \phpbb\event\data(['topic_list' => [42, 43]])); $front_event = new \phpbb\event\data([ @@ -230,4 +230,14 @@ public function test_ucp_tags_are_batch_loaded_and_rendered(): void $listener->add_ucp_topiclist_tags($list_event); self::assertSame($rendered, $list_event['template_vars']['TOPIC_TAGS']); } + + protected function listener($assignments, $renderer, $template): \phpbb\topicprefixes\event\display_listener + { + $language = $this->getMockBuilder('\phpbb\language\language')->disableOriginalConstructor()->getMock(); + $language->expects(self::once()) + ->method('add_lang') + ->with('topic_prefixes', 'phpbb/topicprefixes'); + + return new \phpbb\topicprefixes\event\display_listener($assignments, $renderer, $template, $language); + } } diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index 9f63b76..36ddeea 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -102,6 +102,7 @@ public function test_posting_form_displays_tag_controls($fixture) $this->login(); $crawler = self::request('GET', 'posting.php?mode=post&f=' . self::FORUM_ID . "&sid={$this->sid}"); self::assertGreaterThanOrEqual(2, $crawler->filter('input[name="topic_tags[]"]')->count()); + self::assertStringContainsString('Select or deselect', $crawler->filter('.topic-tag-choices label.topic-tag')->first()->attr('title')); return $fixture; } @@ -173,8 +174,11 @@ public function test_viewtopic_displays_tags($fixture) { $this->login(); $crawler = self::request('GET', 'viewtopic.php?t=' . $fixture['topic_id'] . "&sid={$this->sid}"); - self::assertCount(1, $crawler->filter('h2.topic-title .topic-tag')); - self::assertStringContainsString('PHP 8.4 filter', $crawler->filter('h2.topic-title .topic-tag')->text()); + $tag = $crawler->filter('h2.topic-title .topic-tag'); + self::assertCount(1, $tag); + self::assertStringContainsString('PHP 8.4 filter', $tag->text()); + self::assertSame('Filter topics by β€œPHP 8.4 filter”', $tag->attr('title')); + self::assertSame('Filter topics by β€œPHP 8.4 filter”', $tag->attr('aria-label')); self::assertStringContainsString('topic-tags', $crawler->filter('h2.topic-title')->children()->eq(0)->attr('class')); } @@ -186,7 +190,10 @@ public function test_viewforum_filters_by_tag($fixture) $this->login(); $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . '&tags=' . $fixture['php_id'] . "&sid={$this->sid}"); self::assertStringContainsString('Structured tag title', $crawler->filter('.topiclist.topics')->text()); - self::assertCount(1, $crawler->filter('.topic-tag-filter-panel .topic-tag-selected')); + $selected = $crawler->filter('.topic-tag-filter-panel .topic-tag-selected'); + self::assertCount(1, $selected); + self::assertSame('Remove β€œPHP 8.4 filter” from topic filters', $selected->attr('title')); + self::assertSame('Remove β€œPHP 8.4 filter” from topic filters', $selected->attr('aria-label')); } /** From e0a8c32eb5beecf8d49fe527ff87d9bbb5c80b20 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 21 Sep 2026 07:03:02 -0700 Subject: [PATCH 20/32] Added multibyte prefix test coverage using Japanese text --- migrations/v200_data.php | 7 +++++-- tests/dbal/legacy_migration_test.php | 17 +++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/migrations/v200_data.php b/migrations/v200_data.php index 4cd252b..0b91f76 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -202,15 +202,18 @@ protected function migrate_topic_batch(array $tables, array $topics): void continue; } + // These byte-string functions are intentional. strpos() confirms the + // exact prefix bytes, so its byte length is a safe UTF-8 boundary. + $legacy_length = strlen($legacy_text); if (strpos($topic['topic_title'], $legacy_text) === 0) { - $topic_titles[$topic_id] = substr($topic['topic_title'], strlen($legacy_text)); + $topic_titles[$topic_id] = substr($topic['topic_title'], $legacy_length); } $post_id = (int) $topic['topic_first_post_id']; if ($post_id && $topic['post_subject'] !== null && strpos($topic['post_subject'], $legacy_text) === 0) { - $post_subjects[$post_id] = substr($topic['post_subject'], strlen($legacy_text)); + $post_subjects[$post_id] = substr($topic['post_subject'], $legacy_length); } } diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php index 79c029e..1ddd630 100644 --- a/tests/dbal/legacy_migration_test.php +++ b/tests/dbal/legacy_migration_test.php @@ -36,14 +36,19 @@ protected function setUp(): void $this->tools->sql_column_add('phpbb_topic_prefixes', 'forum_id', array('UINT', 0)); $this->db->sql_query("UPDATE phpbb_topic_prefixes SET prefix_left_id = prefix_id * 2 - 1, prefix_right_id = prefix_id * 2, forum_id = 2"); + $this->db->sql_query('UPDATE phpbb_topic_prefixes + SET ' . $this->db->sql_build_array('UPDATE', array('prefix_tag' => 'バグ')) . ' + WHERE prefix_id = 1'); $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_forums WHERE prefix_id = 4'); - $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'Bug Both tags', topic_prefix_id = 1 WHERE topic_id = 10"); + $this->db->sql_query('UPDATE phpbb_topics + SET ' . $this->db->sql_build_array('UPDATE', array('topic_title' => 'バグ Both tags', 'topic_prefix_id' => 1)) . ' + WHERE topic_id = 10'); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1 WHERE topic_id = 11"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2 WHERE topic_id = 12"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0 WHERE topic_id = 13"); $this->db->sql_query('INSERT INTO phpbb_topics ' . $this->db->sql_build_array('INSERT', array( 'forum_id' => 2, - 'topic_title' => 'Bug Moved topic', + 'topic_title' => 'バグ Moved topic', 'topic_prefix_id' => 1, 'topic_moved_id' => 10, 'topic_visibility' => ITEM_APPROVED, @@ -52,12 +57,12 @@ protected function setUp(): void $this->moved_topic_id = (int) $this->db->sql_nextid(); foreach (array( - array('both', 10, 'Bug Both tags'), + array('both', 10, 'バグ Both tags'), array('unrelated', 11, 'Unrelated first post'), array('php', 12, 'PHP 8.4 PHP only'), array('random', 13, '[Random] No tags'), - array('reply', 10, 'Bug reply subject'), - array('moved', $this->moved_topic_id, 'Bug Moved topic'), + array('reply', 10, 'バグ reply subject'), + array('moved', $this->moved_topic_id, 'バグ Moved topic'), ) as $post) { $sql = 'INSERT INTO phpbb_posts ' . $this->db->sql_build_array('INSERT', array( @@ -109,7 +114,7 @@ public function test_legacy_definitions_titles_subjects_and_idempotency() self::assertSame('[Other] untouched', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 11', 'topic_title')); self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['unrelated'], 'post_subject')); self::assertSame('[Random] No tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 13', 'topic_title')); - self::assertSame('Bug reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['reply'], 'post_subject')); + self::assertSame('バグ reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['reply'], 'post_subject')); self::assertSame('Moved topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = ' . $this->moved_topic_id, 'topic_title')); self::assertSame('Moved topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['moved'], 'post_subject')); self::assertSame(0, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . $this->moved_topic_id, 'total')); From 3983d76ab6f46bd91d5c0ef2522a58fee551b7ea Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 21 Sep 2026 07:24:16 -0700 Subject: [PATCH 21/32] Fix MSSQL - as usual --- migrations/v200_data.php | 19 ++++++++++++- tests/dbal/legacy_migration_test.php | 41 ++++++++++++++++++---------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/migrations/v200_data.php b/migrations/v200_data.php index 0b91f76..7e8b694 100644 --- a/migrations/v200_data.php +++ b/migrations/v200_data.php @@ -273,7 +273,7 @@ protected function bulk_update_text(string $table, string $id_column, string $va { $value_sql = $this->db->sql_case( $id_column . ' = ' . (int) $id, - "'" . $this->db->sql_escape($value) . "'", + $this->sql_text_literal($value), $value_sql ); } @@ -284,4 +284,21 @@ protected function bulk_update_text(string $table, string $id_column, string $va $this->db->sql_query($sql); } } + + /** + * Quote text for use as an SQL literal. + * + * SQL Server requires the N prefix to preserve Unicode text when a literal + * is assigned to an nvarchar column. Other supported DBMS use the standard + * quoted form generated throughout phpBB's DBAL. + * + * @param string $value Text value + * @return string Quoted SQL literal + */ + protected function sql_text_literal(string $value): string + { + $unicode_prefix = strpos($this->db->get_sql_layer(), 'mssql') === 0 ? 'N' : ''; + + return $unicode_prefix . "'" . $this->db->sql_escape($value) . "'"; + } } diff --git a/tests/dbal/legacy_migration_test.php b/tests/dbal/legacy_migration_test.php index 1ddd630..b24688d 100644 --- a/tests/dbal/legacy_migration_test.php +++ b/tests/dbal/legacy_migration_test.php @@ -34,35 +34,36 @@ protected function setUp(): void $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_right_id', array('UINT', 0)); $this->tools->sql_column_add('phpbb_topic_prefixes', 'prefix_parents', array('MTEXT_UNI', '')); $this->tools->sql_column_add('phpbb_topic_prefixes', 'forum_id', array('UINT', 0)); + $pdo = $this->getConnection()->getConnection(); $this->db->sql_query("UPDATE phpbb_topic_prefixes SET prefix_left_id = prefix_id * 2 - 1, prefix_right_id = prefix_id * 2, forum_id = 2"); - $this->db->sql_query('UPDATE phpbb_topic_prefixes - SET ' . $this->db->sql_build_array('UPDATE', array('prefix_tag' => 'バグ')) . ' - WHERE prefix_id = 1'); + $statement = $pdo->prepare('UPDATE phpbb_topic_prefixes SET prefix_tag = ? WHERE prefix_id = 1'); + $statement->execute(array('バグ')); $this->db->sql_query('DELETE FROM phpbb_topic_prefixes_forums WHERE prefix_id = 4'); - $this->db->sql_query('UPDATE phpbb_topics - SET ' . $this->db->sql_build_array('UPDATE', array('topic_title' => 'バグ Both tags', 'topic_prefix_id' => 1)) . ' - WHERE topic_id = 10'); + $statement = $pdo->prepare('UPDATE phpbb_topics SET topic_title = ?, topic_prefix_id = 1 WHERE topic_id = 10'); + $statement->execute(array('バグ ζ—₯本θͺž title')); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Other] untouched', topic_prefix_id = 1 WHERE topic_id = 11"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = 'PHP 8.4 PHP only', topic_prefix_id = 2 WHERE topic_id = 12"); $this->db->sql_query("UPDATE phpbb_topics SET topic_title = '[Random] No tags', topic_prefix_id = 0 WHERE topic_id = 13"); $this->db->sql_query('INSERT INTO phpbb_topics ' . $this->db->sql_build_array('INSERT', array( 'forum_id' => 2, - 'topic_title' => 'バグ Moved topic', + 'topic_title' => 'Temporary moved topic', 'topic_prefix_id' => 1, 'topic_moved_id' => 10, 'topic_visibility' => ITEM_APPROVED, 'topic_type' => POST_NORMAL, ))); $this->moved_topic_id = (int) $this->db->sql_nextid(); + $statement = $pdo->prepare('UPDATE phpbb_topics SET topic_title = ? WHERE topic_id = ?'); + $statement->execute(array('バグ η§»ε‹• topic', $this->moved_topic_id)); foreach (array( - array('both', 10, 'バグ Both tags'), + array('both', 10, 'Temporary first post'), array('unrelated', 11, 'Unrelated first post'), array('php', 12, 'PHP 8.4 PHP only'), array('random', 13, '[Random] No tags'), - array('reply', 10, 'バグ reply subject'), - array('moved', $this->moved_topic_id, 'バグ Moved topic'), + array('reply', 10, 'Temporary reply'), + array('moved', $this->moved_topic_id, 'Temporary moved post'), ) as $post) { $sql = 'INSERT INTO phpbb_posts ' . $this->db->sql_build_array('INSERT', array( @@ -75,6 +76,16 @@ protected function setUp(): void $this->post_ids[$post[0]] = (int) $this->db->sql_nextid(); } + $statement = $pdo->prepare('UPDATE phpbb_posts SET post_subject = ? WHERE post_id = ?'); + foreach (array( + 'both' => 'バグ ζ—₯本θͺž title', + 'reply' => 'バグ θΏ”δΏ‘ subject', + 'moved' => 'バグ η§»ε‹• topic', + ) as $post => $subject) + { + $statement->execute(array($subject, $this->post_ids[$post])); + } + foreach (array( 10 => $this->post_ids['both'], 11 => $this->post_ids['unrelated'], @@ -107,16 +118,16 @@ public function test_legacy_definitions_titles_subjects_and_idempotency() self::assertSame('4A76A8', $this->field('SELECT prefix_color FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_color')); self::assertSame(1, (int) $this->field('SELECT prefix_order FROM phpbb_topic_prefixes WHERE prefix_id = 1', 'prefix_order')); - self::assertSame('Both tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 10', 'topic_title')); - self::assertSame('Both tags', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['both'], 'post_subject')); + self::assertSame('ζ—₯本θͺž title', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 10', 'topic_title')); + self::assertSame('ζ—₯本θͺž title', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['both'], 'post_subject')); self::assertSame('PHP only', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 12', 'topic_title')); self::assertSame('PHP only', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['php'], 'post_subject')); self::assertSame('[Other] untouched', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 11', 'topic_title')); self::assertSame('Unrelated first post', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['unrelated'], 'post_subject')); self::assertSame('[Random] No tags', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = 13', 'topic_title')); - self::assertSame('バグ reply subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['reply'], 'post_subject')); - self::assertSame('Moved topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = ' . $this->moved_topic_id, 'topic_title')); - self::assertSame('Moved topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['moved'], 'post_subject')); + self::assertSame('バグ θΏ”δΏ‘ subject', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['reply'], 'post_subject')); + self::assertSame('η§»ε‹• topic', $this->field('SELECT topic_title FROM phpbb_topics WHERE topic_id = ' . $this->moved_topic_id, 'topic_title')); + self::assertSame('η§»ε‹• topic', $this->field('SELECT post_subject FROM phpbb_posts WHERE post_id = ' . $this->post_ids['moved'], 'post_subject')); self::assertSame(0, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_topics WHERE topic_id = ' . $this->moved_topic_id, 'total')); self::assertSame(1, (int) $this->field('SELECT COUNT(*) AS total FROM phpbb_topic_prefixes_forums WHERE forum_id = 2 AND prefix_id = 4', 'total')); From ce9955d3c3e010326cb6baacc97ef0d20aa3f3e9 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 21 Sep 2026 15:46:45 -0700 Subject: [PATCH 22/32] Handling for 4-byte emojis --- controller/admin_controller.php | 11 ++++-- language/en/acp_topic_prefixes.php | 1 + tags/assignment_manager.php | 2 ++ tags/manager.php | 33 ++++++++++++++++-- tags/renderer.php | 2 +- tests/controller/admin_controller_test.php | 10 ++++-- tests/dbal/assignment_manager_test.php | 13 ++++++++ tests/dbal/tag_manager_test.php | 25 ++++++++++++++ tests/functional/functional_test.php | 39 ++++++++++++++++++++++ tests/tags/renderer_test.php | 12 +++++++ 10 files changed, 140 insertions(+), 8 deletions(-) diff --git a/controller/admin_controller.php b/controller/admin_controller.php index bf42dd0..4484876 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -130,7 +130,7 @@ public function display_settings($editing = false): void $tag_id = (int) $tag['prefix_id']; $this->template->assign_block_vars('tags', [ 'TAG_ID' => $tag_id, - 'TAG_NAME' => $tag['prefix_tag'], + 'TAG_NAME' => utf8_htmlspecialchars($tag['prefix_tag']), 'TAG_COLOR' => '#' . $tag['prefix_color'], 'TAG_TEXT_COLOR' => $this->renderer->contrast_color($tag['prefix_color']), 'TAG_ENABLED' => (bool) $tag['prefix_enabled'], @@ -154,7 +154,7 @@ public function display_settings($editing = false): void 'U_ACTION' => $this->u_action, 'S_EDIT_TAG' => !empty($editing['prefix_id']), 'TAG_ID' => (int) $editing['prefix_id'], - 'TAG_NAME' => $editing['prefix_tag'], + 'TAG_NAME' => utf8_htmlspecialchars($editing['prefix_tag']), 'TAG_COLOR' => '#' . $editing['prefix_color'], 'TAG_ENABLED' => (bool) $editing['prefix_enabled'], 'S_FORUM_OPTIONS' => make_forum_select($editing['forum_ids'], false, false, true), @@ -182,6 +182,11 @@ public function save_tag(int $tag_id): void { $this->trigger_message('TOPIC_TAG_NAME_REQUIRED', E_USER_WARNING); } + $name = manager::normalize_name($name); + if ($name === '') + { + $this->trigger_message('TOPIC_TAG_NAME_TOO_LONG', E_USER_WARNING); + } if ($this->manager->normalize_color($color) === '') { $this->trigger_message('TOPIC_TAG_COLOR_INVALID', E_USER_WARNING); @@ -324,7 +329,7 @@ protected function trigger_message(string $message, int $error = E_USER_NOTICE): */ protected function log(string $tag, string $message): void { - $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $message, time(), [$tag]); + $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, $message, time(), [utf8_encode_ucr($tag)]); } /** diff --git a/language/en/acp_topic_prefixes.php b/language/en/acp_topic_prefixes.php index 9848741..435d9e0 100644 --- a/language/en/acp_topic_prefixes.php +++ b/language/en/acp_topic_prefixes.php @@ -37,6 +37,7 @@ 'TOPIC_TAG_SAVED' => 'Topic tag saved.', 'TOPIC_TAG_TOGGLE_STATE' => 'Enable or disable this topic tag', 'TOPIC_TAG_NAME_REQUIRED' => 'Tag text is required.', + 'TOPIC_TAG_NAME_TOO_LONG' => 'Tag text is too long to store.', 'TOPIC_TAG_COLOR_INVALID' => 'Badge color must be a six-digit hexadecimal color.', 'TOPIC_TAG_NOT_FOUND' => 'Requested topic tag does not exist.', ]); diff --git a/tags/assignment_manager.php b/tags/assignment_manager.php index dd6ba3a..0d657d7 100644 --- a/tags/assignment_manager.php +++ b/tags/assignment_manager.php @@ -322,6 +322,7 @@ public function get_tags_for_topics(array $topic_ids): array { $topic_id = (int) $row['topic_id']; $tag_id = (int) $row['prefix_id']; + $row['prefix_tag'] = manager::decode_name($row['prefix_tag']); $this->topic_tags[$topic_id][$tag_id] = $row; $this->topic_tag_ids[$topic_id][] = $tag_id; } @@ -374,6 +375,7 @@ public function get_tags_for_displayed_topics(array $topic_ids): array $topic_id = (int) $row['display_topic_id']; $tag_id = (int) $row['prefix_id']; unset($row['display_topic_id']); + $row['prefix_tag'] = manager::decode_name($row['prefix_tag']); $tags[$topic_id][$tag_id] = $row; } $this->db->sql_freeresult($result); diff --git a/tags/manager.php b/tags/manager.php index e700d4a..247bdf7 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -23,6 +23,7 @@ class manager { public const DEFAULT_COLOR = '4A76A8'; public const CACHE_KEY = '_topicprefixes_tag_catalog'; + public const MAX_NAME_STORAGE_LENGTH = 255; /** @var driver_interface */ protected $db; @@ -173,7 +174,7 @@ public function get_assignable_tags(int $forum_id, array $tag_ids): array */ public function add_tag(string $name, string $color, bool $enabled, array $forum_ids) { - $name = trim($name); + $name = self::normalize_name($name); $color = $this->normalize_color($color); if ($name === '' || $color === '') { @@ -214,7 +215,7 @@ public function add_tag(string $name, string $color, bool $enabled, array $forum public function update_tag(int $tag_id, string $name, string $color, bool $enabled, array $forum_ids) { $tag_id = (int) $tag_id; - $name = trim($name); + $name = self::normalize_name($name); $color = $this->normalize_color($color); if (!$tag_id || $name === '' || $color === '' || !$this->tag_exists($tag_id)) { @@ -392,6 +393,33 @@ public function normalize_color(string $color): string return preg_match('/^[0-9A-F]{6}$/D', $color) ? $color : ''; } + /** + * Prepare tag text for phpBB's portable Unicode database storage. + * + * Four-byte characters are stored as numeric Unicode references. Reject + * names whose stored representation would exceed the legacy column size. + * + * @param string $name Submitted tag text + * @return string Storage-safe text, or empty string when invalid + */ + public static function normalize_name(string $name): string + { + $name = utf8_encode_ucr(trim($name)); + + return utf8_strlen($name) <= self::MAX_NAME_STORAGE_LENGTH ? $name : ''; + } + + /** + * Restore phpBB's database-safe Unicode references for presentation. + * + * @param string $name Stored tag text + * @return string Display text + */ + public static function decode_name(string $name): string + { + return utf8_decode_ncr($name); + } + /** * Check whether tag exists. * @@ -436,6 +464,7 @@ protected function get_catalog(): array $tag_id = (int) $row['prefix_id']; $forum_id = (int) $row['forum_id']; unset($row['forum_id']); + $row['prefix_tag'] = self::decode_name($row['prefix_tag']); $catalog['tags'][$tag_id] = $row; if ($forum_id) { diff --git a/tags/renderer.php b/tags/renderer.php index 61db97a..5861250 100644 --- a/tags/renderer.php +++ b/tags/renderer.php @@ -67,7 +67,7 @@ public function render(array $tags, int $forum_id = 0, array $selected_ids = [], $rendered[] = [ 'TAG_ID' => $tag_id, - 'TAG_NAME' => $tag['prefix_tag'], + 'TAG_NAME' => utf8_htmlspecialchars($tag['prefix_tag']), 'TAG_COLOR' => '#' . $tag['prefix_color'], 'TAG_TEXT_COLOR' => $this->contrast_color($tag['prefix_color']), 'S_SELECTED' => $is_selected, diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index 198f397..6e9ce7d 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -60,11 +60,11 @@ public function test_display_settings_lists_tags_and_edit_form() { $this->manager->method('get_forum_names_by_tag')->willReturn(array(1 => array('Forum Two'))); $this->manager->method('get_tags')->willReturn(array(1 => array( - 'prefix_id' => 1, 'prefix_tag' => 'Bug', 'prefix_color' => 'D4351C', 'prefix_enabled' => 1, + 'prefix_id' => 1, 'prefix_tag' => '', 'prefix_color' => 'D4351C', 'prefix_enabled' => 1, ))); $this->renderer->method('contrast_color')->with('D4351C')->willReturn('#FFFFFF'); $this->template->expects(self::once())->method('assign_block_vars')->with('tags', self::callback(function ($row) { - return $row['TAG_NAME'] === 'Bug' && $row['FORUM_NAMES'] === array('Forum Two') && $row['TAG_TEXT_COLOR'] === '#FFFFFF'; + return $row['TAG_NAME'] === '<Bug>' && $row['FORUM_NAMES'] === array('Forum Two') && $row['TAG_TEXT_COLOR'] === '#FFFFFF'; })); $this->template->expects(self::once())->method('assign_vars')->with(self::callback(function ($vars) { return $vars['TAG_ID'] === 0 && $vars['S_FORUM_OPTIONS'] === '#forum options#'; @@ -296,6 +296,7 @@ public function error_path_data(): array { return array( array('missing_name', 'TOPIC_TAG_NAME_REQUIRED'), + array('name_too_long', 'TOPIC_TAG_NAME_TOO_LONG'), array('invalid_color', 'TOPIC_TAG_COLOR_INVALID'), array('failed_update', 'TOPIC_TAG_NOT_FOUND'), array('missing_delete', 'TOPIC_TAG_NOT_FOUND'), @@ -324,6 +325,11 @@ public function test_error_paths(string $scenario, string $message): void $this->controller->save_tag(0); break; + case 'name_too_long': + $this->configure_save_request(str_repeat('πŸ˜‡', 29), '#AA00CC'); + $this->controller->save_tag(0); + break; + case 'invalid_color': $this->configure_save_request('Bug', 'invalid'); $this->manager->method('normalize_color')->willReturn(''); diff --git a/tests/dbal/assignment_manager_test.php b/tests/dbal/assignment_manager_test.php index 7efb8d2..508c5d4 100644 --- a/tests/dbal/assignment_manager_test.php +++ b/tests/dbal/assignment_manager_test.php @@ -45,6 +45,19 @@ public function test_batch_retrieval() self::assertSame($after_definitions, $this->db->sql_num_queries()); } + public function test_tag_names_are_decoded_for_topic_display_queries() + { + $this->db->sql_query("UPDATE phpbb_topic_prefixes + SET prefix_tag = '🧲' + WHERE prefix_id = 1"); + $manager = $this->create_assignment_manager(); + + self::assertSame('🧲', $manager->get_tags_for_topics([10])[10][1]['prefix_tag']); + + $this->db->sql_query('UPDATE phpbb_topics SET topic_moved_id = 10 WHERE topic_id = 13'); + self::assertSame('🧲', $manager->get_tags_for_displayed_topics([13])[13][1]['prefix_tag']); + } + /** * Test moving a topic preserves its tags and exposes them for forum filters. */ diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index cd3bbf8..09f6ff7 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -40,6 +40,28 @@ public function test_crud_and_forum_availability() self::assertFalse($manager->get_tag($tag['prefix_id'])); } + public function test_four_byte_tag_names_use_phpbb_unicode_storage() + { + $manager = $this->create_tag_manager(); + $tag = $manager->add_tag('πŸ˜‡', '4A76A8', true, array(2)); + + self::assertSame('πŸ˜‡', $tag['prefix_tag']); + $result = $this->db->sql_query('SELECT prefix_tag + FROM phpbb_topic_prefixes + WHERE prefix_id = ' . (int) $tag['prefix_id']); + self::assertSame('😇', $this->db->sql_fetchfield('prefix_tag')); + $this->db->sql_freeresult($result); + + $tag = $manager->update_tag($tag['prefix_id'], 'Fixed πŸš€', '4A76A8', true, array(2)); + self::assertSame('Fixed πŸš€', $tag['prefix_tag']); + $result = $this->db->sql_query('SELECT prefix_tag + FROM phpbb_topic_prefixes + WHERE prefix_id = ' . (int) $tag['prefix_id']); + self::assertSame('Fixed 🚀', $this->db->sql_fetchfield('prefix_tag')); + $this->db->sql_freeresult($result); + self::assertSame('Fixed πŸš€', $this->create_tag_manager()->get_tag($tag['prefix_id'])['prefix_tag']); + } + public function test_delete_cascades_availability_and_topic_assignments() { $manager = $this->create_tag_manager(); @@ -113,6 +135,8 @@ public function test_color_validation() $manager = $this->create_tag_manager(); self::assertSame('AABBCC', $manager->normalize_color('#aabbcc')); self::assertSame('', $manager->normalize_color('red')); + self::assertSame('😇', \phpbb\topicprefixes\tags\manager::normalize_name('πŸ˜‡')); + self::assertSame('', \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('πŸ˜‡', 29))); } /** @@ -123,6 +147,7 @@ public function test_invalid_and_missing_tag_operations(): void $manager = $this->create_tag_manager(); self::assertFalse($manager->add_tag('', 'FFFFFF', true, [2])); + self::assertFalse($manager->add_tag(str_repeat('πŸ˜‡', 29), 'FFFFFF', true, [2])); self::assertFalse($manager->add_tag('Invalid color', 'red', true, [2])); self::assertFalse($manager->update_tag(999, 'Missing', 'FFFFFF', true, [2])); self::assertFalse($manager->set_enabled(999, true)); diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index 36ddeea..dd544a1 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -38,6 +38,45 @@ public function test_acp_module() return true; } + /** + * @depends test_acp_module + */ + public function test_acp_accepts_emoji_tag($module_ready) + { + self::assertTrue($module_ready); + $this->login(); + $this->admin_login(); + $crawler = $this->acp_page(); + $form = $crawler->selectButton($this->lang('SUBMIT'))->form(array( + 'tag_name' => 'πŸ˜‡', + 'tag_color' => '#4a76a8', + 'tag_enabled' => 1, + 'forum_ids' => array(self::FORUM_ID), + )); + $crawler = self::submit($form); + $this->assertContainsLang('TOPIC_TAG_SAVED', $crawler->text()); + + $this->get_db(); + $result = $this->db->sql_query("SELECT prefix_id + FROM phpbb_topic_prefixes + WHERE prefix_tag = '😇'"); + $tag_id = (int) $this->db->sql_fetchfield('prefix_id'); + $this->db->sql_freeresult($result); + self::assertGreaterThan(0, $tag_id); + + $crawler = $this->acp_page(); + self::assertStringContainsString('πŸ˜‡', $crawler->filter('.topic-tag')->text()); + + $topic = $this->create_topic(self::FORUM_ID, 'Emoji tag topic', 'Emoji tag post', array( + 'topic_tags' => array($tag_id), + 'topic_tags_present' => 1, + )); + $crawler = self::request('GET', 'viewtopic.php?t=' . $topic['topic_id'] . "&sid={$this->sid}"); + self::assertStringContainsString('πŸ˜‡', $crawler->filter('h2.topic-title .topic-tag')->text()); + $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . "&sid={$this->sid}"); + self::assertStringContainsString('πŸ˜‡', $crawler->filter('ul.topiclist .topic-tag')->text()); + } + /** * @depends test_acp_module */ diff --git a/tests/tags/renderer_test.php b/tests/tags/renderer_test.php index e507e60..685b740 100644 --- a/tests/tags/renderer_test.php +++ b/tests/tags/renderer_test.php @@ -89,4 +89,16 @@ public function test_rendered_tags_without_forum_are_not_filterable(): void self::assertSame('', $rendered[0]['U_FILTER']); } + + public function test_rendered_tag_names_are_plain_text(): void + { + $renderer = new \phpbb\topicprefixes\tags\renderer('./', 'php'); + $rendered = $renderer->render([[ + 'prefix_id' => 1, + 'prefix_tag' => 'πŸ˜‡', + 'prefix_color' => '4A76A8', + ]]); + + self::assertSame('<strong>πŸ˜‡</strong>', $rendered[0]['TAG_NAME']); + } } From 215f7e00fde70854bd45f7b2831f4bfe991e6d53 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 21 Sep 2026 16:47:35 -0700 Subject: [PATCH 23/32] Enforce a 50 char limit for topic prefix/tags --- adm/style/acp_topic_prefixes.html | 2 +- controller/admin_controller.php | 3 +-- tags/manager.php | 8 +++++++- tests/controller/admin_controller_test.php | 23 ++++++++++++++++++++++ tests/dbal/tag_manager_test.php | 2 ++ tests/functional/functional_test.php | 12 ++++++----- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/adm/style/acp_topic_prefixes.html b/adm/style/acp_topic_prefixes.html index 7a769c4..246af15 100644 --- a/adm/style/acp_topic_prefixes.html +++ b/adm/style/acp_topic_prefixes.html @@ -54,7 +54,7 @@

{{ lang('TOPIC_TAGS') }}

{{ lang(S_EDIT_TAG ? 'EDIT_TOPIC_TAG' : 'CREATE_TOPIC_TAG') }}
-
+

{{ lang('TOPIC_TAG_COLOR_EXPLAIN') }}
diff --git a/controller/admin_controller.php b/controller/admin_controller.php index 4484876..9ca39f4 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -182,8 +182,7 @@ public function save_tag(int $tag_id): void { $this->trigger_message('TOPIC_TAG_NAME_REQUIRED', E_USER_WARNING); } - $name = manager::normalize_name($name); - if ($name === '') + if (manager::normalize_name($name) === '') { $this->trigger_message('TOPIC_TAG_NAME_TOO_LONG', E_USER_WARNING); } diff --git a/tags/manager.php b/tags/manager.php index 247bdf7..3b7bfc6 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -23,6 +23,7 @@ class manager { public const DEFAULT_COLOR = '4A76A8'; public const CACHE_KEY = '_topicprefixes_tag_catalog'; + public const MAX_NAME_LENGTH = 50; public const MAX_NAME_STORAGE_LENGTH = 255; /** @var driver_interface */ @@ -404,7 +405,12 @@ public function normalize_color(string $color): string */ public static function normalize_name(string $name): string { - $name = utf8_encode_ucr(trim($name)); + $name = trim($name); + if (utf8_strlen($name) > self::MAX_NAME_LENGTH) + { + return ''; + } + $name = utf8_encode_ucr($name); return utf8_strlen($name) <= self::MAX_NAME_STORAGE_LENGTH ? $name : ''; } diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index 6e9ce7d..328bc93 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -148,6 +148,29 @@ public function test_save_creates_tag(): void self::assertStringContainsString('TOPIC_TAG_SAVED', self::$triggered_message); } + public function test_save_passes_valid_emoji_name_to_manager_once(): void + { + $name = str_repeat('πŸ˜‡', 6); + $this->request->method('is_set_post')->with('submit')->willReturn(true); + $this->request->method('variable')->willReturnCallback(function ($key, $default) use ($name) { + return array( + 'tag_name' => $name, + 'tag_color' => '#4A76A8', + 'tag_enabled' => 1, + 'forum_ids' => array(2), + )[$key] ?? $default; + }); + $this->manager->method('normalize_color')->willReturn('4A76A8'); + $this->manager->expects(self::once()) + ->method('add_tag') + ->with($name, '#4A76A8', 1, array(2)) + ->willReturn(array('prefix_tag' => $name)); + + $this->controller->save_tag(0); + + self::assertStringContainsString('TOPIC_TAG_SAVED', self::$triggered_message); + } + /** * Test saving an existing tag uses update path. */ diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index 09f6ff7..8d044dd 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -135,6 +135,8 @@ public function test_color_validation() $manager = $this->create_tag_manager(); self::assertSame('AABBCC', $manager->normalize_color('#aabbcc')); self::assertSame('', $manager->normalize_color('red')); + self::assertSame(str_repeat('a', 50), \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('a', 50))); + self::assertSame('', \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('a', 51))); self::assertSame('😇', \phpbb\topicprefixes\tags\manager::normalize_name('πŸ˜‡')); self::assertSame('', \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('πŸ˜‡', 29))); } diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index dd544a1..01c8b5b 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -34,6 +34,7 @@ public function test_acp_module() $this->assertContainsLang('TOPIC_TAGS', $crawler->filter('#main')->text()); self::assertCount(1, $crawler->filter('input[type="color"]')); self::assertCount(1, $crawler->filter('select[name="forum_ids[]"][multiple]')); + self::assertSame('50', $crawler->filter('input[name="tag_name"]')->attr('maxlength')); return true; } @@ -44,11 +45,12 @@ public function test_acp_module() public function test_acp_accepts_emoji_tag($module_ready) { self::assertTrue($module_ready); + $emoji_name = str_repeat('πŸ˜‡', 6); $this->login(); $this->admin_login(); $crawler = $this->acp_page(); $form = $crawler->selectButton($this->lang('SUBMIT'))->form(array( - 'tag_name' => 'πŸ˜‡', + 'tag_name' => $emoji_name, 'tag_color' => '#4a76a8', 'tag_enabled' => 1, 'forum_ids' => array(self::FORUM_ID), @@ -59,22 +61,22 @@ public function test_acp_accepts_emoji_tag($module_ready) $this->get_db(); $result = $this->db->sql_query("SELECT prefix_id FROM phpbb_topic_prefixes - WHERE prefix_tag = '😇'"); + WHERE prefix_tag = '" . str_repeat('😇', 6) . "'"); $tag_id = (int) $this->db->sql_fetchfield('prefix_id'); $this->db->sql_freeresult($result); self::assertGreaterThan(0, $tag_id); $crawler = $this->acp_page(); - self::assertStringContainsString('πŸ˜‡', $crawler->filter('.topic-tag')->text()); + self::assertStringContainsString($emoji_name, $crawler->filter('.topic-tag')->text()); $topic = $this->create_topic(self::FORUM_ID, 'Emoji tag topic', 'Emoji tag post', array( 'topic_tags' => array($tag_id), 'topic_tags_present' => 1, )); $crawler = self::request('GET', 'viewtopic.php?t=' . $topic['topic_id'] . "&sid={$this->sid}"); - self::assertStringContainsString('πŸ˜‡', $crawler->filter('h2.topic-title .topic-tag')->text()); + self::assertStringContainsString($emoji_name, $crawler->filter('h2.topic-title .topic-tag')->text()); $crawler = self::request('GET', 'viewforum.php?f=' . self::FORUM_ID . "&sid={$this->sid}"); - self::assertStringContainsString('πŸ˜‡', $crawler->filter('ul.topiclist .topic-tag')->text()); + self::assertStringContainsString($emoji_name, $crawler->filter('ul.topiclist .topic-tag')->text()); } /** From f7136702c0ef46f0e81efd1b047e9a56370668c0 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Mon, 21 Sep 2026 17:03:47 -0700 Subject: [PATCH 24/32] Make all three stateless text utilities static --- controller/admin_controller.php | 2 +- tags/manager.php | 6 +++--- tests/controller/admin_controller_test.php | 5 ----- tests/dbal/tag_manager_test.php | 4 ++-- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/controller/admin_controller.php b/controller/admin_controller.php index 9ca39f4..9cf65ed 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -186,7 +186,7 @@ public function save_tag(int $tag_id): void { $this->trigger_message('TOPIC_TAG_NAME_TOO_LONG', E_USER_WARNING); } - if ($this->manager->normalize_color($color) === '') + if (manager::normalize_color($color) === '') { $this->trigger_message('TOPIC_TAG_COLOR_INVALID', E_USER_WARNING); } diff --git a/tags/manager.php b/tags/manager.php index 3b7bfc6..d9ca4aa 100644 --- a/tags/manager.php +++ b/tags/manager.php @@ -176,7 +176,7 @@ public function get_assignable_tags(int $forum_id, array $tag_ids): array public function add_tag(string $name, string $color, bool $enabled, array $forum_ids) { $name = self::normalize_name($name); - $color = $this->normalize_color($color); + $color = self::normalize_color($color); if ($name === '' || $color === '') { return false; @@ -217,7 +217,7 @@ public function update_tag(int $tag_id, string $name, string $color, bool $enabl { $tag_id = (int) $tag_id; $name = self::normalize_name($name); - $color = $this->normalize_color($color); + $color = self::normalize_color($color); if (!$tag_id || $name === '' || $color === '' || !$this->tag_exists($tag_id)) { return false; @@ -388,7 +388,7 @@ public function get_forum_names_by_tag(): array * @param string $color Submitted color * @return string Normalized color, or empty string when invalid */ - public function normalize_color(string $color): string + public static function normalize_color(string $color): string { $color = strtoupper(ltrim(trim($color), '#')); return preg_match('/^[0-9A-F]{6}$/D', $color) ? $color : ''; diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index 328bc93..60cdc33 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -134,7 +134,6 @@ public function test_save_creates_tag(): void 'forum_ids' => array(2, 3), )[$name] ?? $default; }); - $this->manager->expects(self::once())->method('normalize_color')->with('#AA00CC')->willReturn('AA00CC'); $this->manager->expects(self::once()) ->method('add_tag') ->with('Security', '#AA00CC', 1, array(2, 3)) @@ -160,7 +159,6 @@ public function test_save_passes_valid_emoji_name_to_manager_once(): void 'forum_ids' => array(2), )[$key] ?? $default; }); - $this->manager->method('normalize_color')->willReturn('4A76A8'); $this->manager->expects(self::once()) ->method('add_tag') ->with($name, '#4A76A8', 1, array(2)) @@ -185,7 +183,6 @@ public function test_save_updates_tag(): void 'forum_ids' => array(3), )[$name] ?? $default; }); - $this->manager->method('normalize_color')->willReturn('00AA00'); $this->manager->expects(self::once()) ->method('update_tag') ->with(1, 'Updated', '#00AA00', 0, array(3)) @@ -355,13 +352,11 @@ public function test_error_paths(string $scenario, string $message): void case 'invalid_color': $this->configure_save_request('Bug', 'invalid'); - $this->manager->method('normalize_color')->willReturn(''); $this->controller->save_tag(0); break; case 'failed_update': $this->configure_save_request('Bug', '#AA00CC'); - $this->manager->method('normalize_color')->willReturn('AA00CC'); $this->manager->method('update_tag')->willReturn(false); $this->controller->save_tag(999); break; diff --git a/tests/dbal/tag_manager_test.php b/tests/dbal/tag_manager_test.php index 8d044dd..daf98d3 100644 --- a/tests/dbal/tag_manager_test.php +++ b/tests/dbal/tag_manager_test.php @@ -133,8 +133,8 @@ public function test_move_changes_global_order() public function test_color_validation() { $manager = $this->create_tag_manager(); - self::assertSame('AABBCC', $manager->normalize_color('#aabbcc')); - self::assertSame('', $manager->normalize_color('red')); + self::assertSame('AABBCC', \phpbb\topicprefixes\tags\manager::normalize_color('#aabbcc')); + self::assertSame('', \phpbb\topicprefixes\tags\manager::normalize_color('red')); self::assertSame(str_repeat('a', 50), \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('a', 50))); self::assertSame('', \phpbb\topicprefixes\tags\manager::normalize_name(str_repeat('a', 51))); self::assertSame('😇', \phpbb\topicprefixes\tags\manager::normalize_name('πŸ˜‡')); From d9e7d99ee66969e58c48862c3518f9e2413dded2 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 22 Sep 2026 05:02:29 -0700 Subject: [PATCH 25/32] Rename to Topic Tags --- CHANGELOG.md | 1 + README.md | 6 +++--- build.xml | 2 +- composer.json | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4eebe8..aca0e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### 2.0.0 - TBD +- Renamed the user-facing extension from phpBB Topic Prefixes to phpBB Topic Tags. - Replaced title text prefixes with relational topic tag metadata. - Added multiple colored tags per topic and automatic badge text contrast. - Added global ACP tag management with forum availability controls. diff --git a/README.md b/README.md index 4d03f56..94ed5e5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# phpBB Topic Prefixes +# phpBB Topic Tags -This is the repository for the development of the Topic Prefixes phpBB extension. +This is the repository for development of phpBB Topic Tags, formerly named Topic Prefixes. [![Build Status](https://github.com/phpbb-extensions/topicprefixes/actions/workflows/tests.yml/badge.svg)](https://github.com/phpbb-extensions/topicprefixes/actions) [![codecov](https://codecov.io/gh/phpbb-extensions/topicprefixes/branch/master/graph/badge.svg?token=Dc0GWOeQWj)](https://codecov.io/gh/phpbb-extensions/topicprefixes) -The phpBB Topic Prefixes extension provides administrator-curated topic tags. Tags are structured topic metadata and are displayed separately from topic titles as colored badges. Features include: +The phpBB Topic Tags extension provides administrator-curated topic tags. Tags are structured topic metadata and are displayed separately from topic titles as colored badges. Features include: - Create, edit, order, enable, disable, and color topic tags. - Make each tag available in one or more forums. diff --git a/build.xml b/build.xml index b3125a4..5f8e918 100644 --- a/build.xml +++ b/build.xml @@ -1,5 +1,5 @@ - +