From f8447c8bfd4a42824ee91922e227baeacd184e41 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 12:26:14 +0100 Subject: [PATCH 01/19] Warn in the Upgrade Notice that 1.2.4's comments stay public A site that goes back to 1.2.4 after updating stores its new internal comments approved, and the migration that moves v1's comments does not run again on the next update, so those comments stay public. The owner accepts the gap, given the plugin's installation count, and has the Upgrade Notice say so before anyone updates. The notice keeps under wordpress.org's 300 characters by trimming its other sentences. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/internal-comments/readme.txt b/plugins/internal-comments/readme.txt index c090d34..251b716 100644 --- a/plugins/internal-comments/readme.txt +++ b/plugins/internal-comments/readme.txt @@ -81,7 +81,7 @@ You can open a community support ticket here at [wordpress.org](https://wordpres == Upgrade Notice == = 2.0.0 = -Requires PHP 8.5 and WordPress 7.1. Internal comments move to a status of their own in the background, so going back to 1.2.4 hides them. Reading one also needs access to edit its post. Hooks move to dws_ic/ names; v1's AJAX actions, accessors and framework hooks are removed. +Requires PHP 8.5 and WordPress 7.1. Internal comments move to a status of their own, so 1.2.4 no longer shows them, and any it posts stay public after a later update. Reading one needs access to edit its post. Hooks move to dws_ic/; v1's AJAX actions, accessors and framework hooks are removed. == Changelog == From 2807255887133dfb0373a68f4eaea9d202ed5ba5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 12:36:23 +0100 Subject: [PATCH 02/19] Pin a refused private comment's message to say it was not posted The refusal of a private comment the site disallows said only that the user could not post one here, which reads as a rule rather than as the fate of the text just sent. The owner wants it to say the comment was not posted and why. The row expects the refusal's code and a message saying the comment was not posted. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/tests/Integration/RestRoutesTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/RestRoutesTest.php b/plugins/internal-comments/tests/Integration/RestRoutesTest.php index bbc2124..a3629d9 100644 --- a/plugins/internal-comments/tests/Integration/RestRoutesTest.php +++ b/plugins/internal-comments/tests/Integration/RestRoutesTest.php @@ -141,6 +141,8 @@ public function test_a_comment_is_private_only_where_the_site_allows_it(): void } self::assertSame( 201, $allowed->get_status() ); self::assertSame( 403, $refused->get_status() ); + self::assertSame( 'dws_ic_cannot_be_private', $refused->get_data()['code'] ?? null ); + self::assertStringContainsString( 'was not posted', $refused->get_data()['message'] ?? '' ); self::assertSame( 201, $public->get_status() ); self::assertSame( array( 'Private' => true, 'Public' => false ), $private ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- Two expected values. } From 99eab41df14b422c681d837cd76e62408e42924e Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 12:37:04 +0100 Subject: [PATCH 03/19] Say why a refused private comment was not posted The REST route refuses a private comment where the site's setting or the user's capabilities no longer allow one, and its message said only that the user could not post one here, leaving open whether the text just sent was stored. The message now says the comment was not posted because the site's settings no longer let the user post private internal comments, and how to post it for everyone instead. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/src/RestRoutes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/internal-comments/src/RestRoutes.php b/plugins/internal-comments/src/RestRoutes.php index 1052df6..854146c 100644 --- a/plugins/internal-comments/src/RestRoutes.php +++ b/plugins/internal-comments/src/RestRoutes.php @@ -221,7 +221,7 @@ public function create_comment( \WP_REST_Request $request ): \WP_REST_Response|\ // A comment its author meant to keep private is never stored for every reader instead. $private = true === $request['private']; if ( $private && ! ( dws_ic_are_enabled_private_comments() && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) ) ) { - return new \WP_Error( 'dws_ic_cannot_be_private', __( 'You cannot post a private internal comment here. Clear the private option to post it for everyone who can see internal comments.', 'internal-comments' ), array( 'status' => 403 ) ); + return new \WP_Error( 'dws_ic_cannot_be_private', __( 'This comment was not posted, because the site\'s settings no longer let you post private internal comments. Clear the private option to post it for everyone who can see internal comments.', 'internal-comments' ), array( 'status' => 403 ) ); } /** From e513da356cb5d148373ac7326f8b74ca890fc932 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 12:56:18 +0100 Subject: [PATCH 04/19] Pin core's edit_comment needing both capabilities on internal comments Core trashes, deletes, restores and moderates a comment on edit_comment alone: its Comments screen, its AJAX and bulk actions, and XML-RPC's wp.deleteComment. With edit_comment mapped to the edit capability only, a role given edit rights without trash rights could trash or delete internal comments there. The owner ruled that edit_comment on an internal comment asks for both capabilities. The row gives editors edit_others without trash_others and expects the meta box's edit capability granted, its trash capability refused and edit_comment refused, then edit_comment granted once both are held. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/PermissionsTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/PermissionsTest.php b/plugins/internal-comments/tests/Integration/PermissionsTest.php index 3fc3623..eea0e85 100644 --- a/plugins/internal-comments/tests/Integration/PermissionsTest.php +++ b/plugins/internal-comments/tests/Integration/PermissionsTest.php @@ -92,6 +92,29 @@ public function test_editing_an_internal_comment_through_core_needs_the_edit_cap self::assertTrue( user_can( self::$users['administrator'], 'edit_comment', $internal ) ); } + // Core trashes, deletes, restores and moderates a comment on edit_comment alone, so it asks for both capabilities, while the meta box asks for each on its own. + public function test_core_changes_to_an_internal_comment_need_the_edit_and_the_trash_capability(): void { + $internal = $this->comment( $this->post( 'author' ), 'author' ); + $editor = get_role( 'editor' ); + $editor?->add_cap( DWS_IC_CAP_EDIT_OTHERS ); + + try { + $edit_only = array( + user_can( self::$users['editor'], 'edit_dws_internal_comment', $internal ), + user_can( self::$users['editor'], 'trash_dws_internal_comment', $internal ), + user_can( self::$users['editor'], 'edit_comment', $internal ), + ); + $editor?->add_cap( DWS_IC_CAP_TRASH_OTHERS ); + $both = user_can( self::$users['editor'], 'edit_comment', $internal ); + } finally { + $editor?->remove_cap( DWS_IC_CAP_EDIT_OTHERS ); + $editor?->remove_cap( DWS_IC_CAP_TRASH_OTHERS ); + } + + self::assertSame( array( true, false, false ), $edit_only ); + self::assertTrue( $both ); + } + public function test_the_meta_capabilities_refuse_a_comment_that_is_not_internal(): void { $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post( 'administrator' ) ) ); From bdc21181f3d681414b97303d80d54a8ecf85884f Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 12:57:39 +0100 Subject: [PATCH 05/19] Ask for both capabilities when core checks edit_comment Core trashes, deletes, restores and moderates a comment on edit_comment alone, through the Comments screen, its AJAX and bulk actions, and XML-RPC's wp.deleteComment, so a role given edit rights without trash rights could trash or delete internal comments there. Permissions maps edit_comment on an internal comment to the edit and the trash capability together. The plugin's own routes check each meta capability on its own, so a role that can only edit still edits in the meta box; core's comment edit screen opens only for users who hold both, as AGENTS.md's limitations now say. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 2 +- plugins/internal-comments/changelog/core-comment-actions | 4 ++++ plugins/internal-comments/src/Permissions.php | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 plugins/internal-comments/changelog/core-comment-actions diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index e83c5c6..62476f3 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -89,6 +89,6 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. - Core's comment edit screen shows author, date and status fields for an internal comment, which `Comments` keeps as stored. - When a private comment is deleted permanently, its replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. -- Core's Comments screen trashes or deletes an internal comment for any user who can edit it, because core checks only `edit_comment` there and offers no hook to refuse; the meta box's route and core's REST API check the trash capability. +- Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. - The bundled GitHub updater ships inert, since the entry file has no `Update URI`, and wordpress.org's reviewers may ask to remove it. diff --git a/plugins/internal-comments/changelog/core-comment-actions b/plugins/internal-comments/changelog/core-comment-actions new file mode 100644 index 0000000..27d33b9 --- /dev/null +++ b/plugins/internal-comments/changelog/core-comment-actions @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +WordPress's comment edit screen, Comments screen actions and XML-RPC need both the capability to edit an internal comment and the capability to trash it, since WordPress trashes, deletes and moderates on the same check. diff --git a/plugins/internal-comments/src/Permissions.php b/plugins/internal-comments/src/Permissions.php index fa11b52..f111f22 100644 --- a/plugins/internal-comments/src/Permissions.php +++ b/plugins/internal-comments/src/Permissions.php @@ -51,8 +51,9 @@ public function register_hooks(): void { * @return list */ public function map_meta_cap( array $caps, string $cap, int $user_id, array $args ): array { + // Core trashes, deletes, restores and moderates a comment on edit_comment alone, so it asks for both capabilities. if ( 'edit_comment' === $cap ) { - return true === dws_ic_is_internal_comment( $args[0] ?? null ) ? \array_values( map_meta_cap( 'edit_dws_internal_comment', $user_id, ...$args ) ) : $caps; + return true === dws_ic_is_internal_comment( $args[0] ?? null ) ? \array_values( \array_unique( \array_merge( map_meta_cap( 'edit_dws_internal_comment', $user_id, ...$args ), map_meta_cap( 'trash_dws_internal_comment', $user_id, ...$args ) ) ) ) : $caps; } if ( ! \in_array( $cap, array( 'see_dws_internal_comment', 'edit_dws_internal_comment', 'trash_dws_internal_comment' ), true ) ) { From 0e47c87d11d18378baa560921c286c257f3a20fc Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:03:09 +0100 Subject: [PATCH 06/19] Pin the comment edit screen hiding what an internal comment keeps WordPress's comment edit screen offers an internal comment's author, status and date, which a save keeps as stored, so a change there reads "Comment updated" and is dropped; its status line is blank and no status option is chosen. The owner ruled that the screen hides them. The row expects a style hiding the author box, the status section and the date section on the edit screen of an internal comment, and none for a public one. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AdminScreensTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index e29a770..68bed36 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -248,6 +248,29 @@ public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_ self::assertSame( '', $screens->add_privacy_field( '', get_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ) ); } + // Saving keeps an internal comment's author, status and date as stored, so the edit screen does not offer them. + public function test_the_comment_edit_screen_hides_the_fields_an_internal_comment_keeps(): void { + $screens = new AdminScreens(); + $styles = static function ( int $comment ) use ( $screens ): string { + $GLOBALS['comment'] = get_comment( $comment ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stands in for the edit screen's comment. + try { + $screens->enqueue_comment_screen_style( 'comment.php' ); + + return \implode( '', (array) wp_styles()->get_data( 'internal-comments-comment-screen', 'after' ) ); + } finally { + unset( $GLOBALS['comment'] ); + wp_dequeue_style( 'internal-comments-comment-screen' ); + wp_deregister_style( 'internal-comments-comment-screen' ); + } + }; + + $internal = $styles( $this->comment( 'Internal' ) ); + foreach ( array( '#namediv', '#comment-status', '.misc-pub-curtime' ) as $selector ) { + self::assertStringContainsString( $selector, $internal ); + } + self::assertSame( '', $styles( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ); + } + public function test_the_posts_list_loads_the_quick_views_assets(): void { ( new AdminScreens() )->enqueue_list_assets( 'edit.php' ); From e5cf5e1b8530e8226c1c471c22efd6e05f7f8222 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:06:18 +0100 Subject: [PATCH 07/19] Hide what an internal comment keeps on the comment edit screen WordPress's comment edit screen offered an internal comment's author, status and date, which a save keeps as stored, so a change there read "Comment updated" and was dropped; the status line was blank, with no status option chosen. On that screen, AdminScreens hides the author box, the status section and the date section of an internal comment through an inline style, so the screen shows only what saving changes: the text, the author's privacy choice and the revisions. The update guard still keeps the hidden fields, which the form posts, as stored. A browser row, written after the style and checked failing with it switched off, opens the screen from the Comments screen's Edit link. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/comment-edit-screen | 4 +++ .../internal-comments/src/AdminScreens.php | 23 +++++++++++++ .../tests/e2e/admin-screens.spec.ts | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 plugins/internal-comments/changelog/comment-edit-screen diff --git a/plugins/internal-comments/changelog/comment-edit-screen b/plugins/internal-comments/changelog/comment-edit-screen new file mode 100644 index 0000000..494ca65 --- /dev/null +++ b/plugins/internal-comments/changelog/comment-edit-screen @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +WordPress's comment edit screen hides an internal comment's author, status and date, which saving keeps as stored. diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index f6036d8..e644af7 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -48,6 +48,7 @@ public function register_hooks(): void { add_action( "manage_{$orders_screen}_custom_column", array( $this, 'render_order_cell' ), 10, 2 ); } add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_list_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_comment_screen_style' ) ); add_action( 'admin_menu', array( $this, 'add_comments_view' ) ); add_filter( 'admin_comment_types_dropdown', array( $this, 'add_comment_type' ) ); add_filter( 'comments_list_table_query_args', array( $this, 'filter_list_table_args' ) ); @@ -378,6 +379,28 @@ public function filter_bulk_actions( array $actions ): array { return DWS_IC_COMMENT_TYPE === $type ? \array_intersect_key( $actions, \array_flip( array( 'trash', 'untrash', 'delete' ) ) ) : $actions; } + /** + * Hides the author, status and date of an internal comment on WordPress's comment edit screen, since saving keeps them as stored. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $hook_suffix The admin page's hook suffix. + * + * @return void + */ + public function enqueue_comment_screen_style( string $hook_suffix ): void { + // The comment screen loads its comment before it prints the page's head. + $comment = $GLOBALS['comment'] ?? null; + if ( 'comment.php' !== $hook_suffix || ! $comment instanceof \WP_Comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type ) { + return; + } + + wp_register_style( Plugin::SLUG . '-comment-screen', false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A style without a file has no version to bust. + wp_enqueue_style( Plugin::SLUG . '-comment-screen' ); + wp_add_inline_style( Plugin::SLUG . '-comment-screen', '#namediv, #comment-status, .misc-pub-curtime { display: none; }' ); + } + /** * Adds the revision history meta box to the edit screen of an internal comment the user can see. * diff --git a/plugins/internal-comments/tests/e2e/admin-screens.spec.ts b/plugins/internal-comments/tests/e2e/admin-screens.spec.ts index ed41c37..9dd0002 100644 --- a/plugins/internal-comments/tests/e2e/admin-screens.spec.ts +++ b/plugins/internal-comments/tests/e2e/admin-screens.spec.ts @@ -69,4 +69,36 @@ test.describe( 'Posts list and Comments screen', () => { 'Seen in the list' ); } ); + + test( 'an internal comment’s edit screen shows only what saving changes', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( + 'edit-comments.php', + 'comment_type=dws_internal' + ); + const href = await page + .locator( '#the-comment-list' ) + .getByRole( 'link', { name: 'Edit this comment' } ) + .first() + .getAttribute( 'href' ); + const id = new URL( href ?? '', page.url() ).searchParams.get( 'c' ); + + await admin.visitAdminPage( + 'comment.php', + `action=editcomment&c=${ id ?? '' }` + ); + + await expect( page.locator( '#content' ) ).toHaveValue( + 'Seen in the list' + ); + for ( const selector of [ + '#namediv', + '#comment-status', + '.misc-pub-curtime', + ] ) { + await expect( page.locator( selector ) ).toBeHidden(); + } + } ); } ); From a2bb34af4abc5396143b468f2437691d075605ae Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:34:00 +0100 Subject: [PATCH 08/19] Pin the Comments screen's Trash count to the comments a user may list The Comments screen leaves internal comments out of its list for users without the table capability, but WordPress counts its views with queries of their own, so an editor saw a Trash count that included the trashed internal comments the list hid. The owner ruled that the count match the list. The row trashes an internal and a public comment and expects an editor's Trash count one lower than an administrator's on that screen, with the cached counts still WordPress's own; it sets the screen without firing current_screen, which another plugin in the shared environment hooks with admin-only code. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AdminScreensTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index 68bed36..b2aa869 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -271,6 +271,33 @@ public function test_the_comment_edit_screen_hides_the_fields_an_internal_commen self::assertSame( '', $styles( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ); } + // WordPress counts the Comments screen's views apart from its list, and caches the counts for every user. + public function test_the_comments_screen_counts_only_the_trashed_comments_the_user_may_list(): void { + $internal = $this->comment( 'Trashed' ); + $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ); + wp_trash_comment( $internal ); + wp_trash_comment( $public ); + $screens = new AdminScreens(); + add_filter( 'wp_count_comments', array( $screens, 'count_listed_comments' ), 10, 2 ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_set_current_user( self::$users['editor'] ); + $editor = (int) wp_count_comments()->trash; + $cached = wp_cache_get( 'comments-0', 'counts' ); + wp_set_current_user( self::$users['administrator'] ); + $administrator = (int) wp_count_comments()->trash; + } finally { + remove_filter( 'wp_count_comments', array( $screens, 'count_listed_comments' ) ); + unset( $GLOBALS['current_screen'] ); + } + + self::assertSame( $administrator - 1, $editor ); + self::assertIsObject( $cached ); + self::assertSame( $administrator, (int) $cached->trash ); + } + public function test_the_posts_list_loads_the_quick_views_assets(): void { ( new AdminScreens() )->enqueue_list_assets( 'edit.php' ); From 51a3ff7adb65f92a086c64ef4e106c8720c1013a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:40:26 +0100 Subject: [PATCH 09/19] Describe the comment edit screen's hidden fields in the limitations AGENTS.md still said core's comment edit screen shows an internal comment's author, date and status, which e5cf5e1b hid. The limitation now names what holds: the style relies on core's element ids, and saving keeps those fields as stored either way. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index 62476f3..465aa4d 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -87,7 +87,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - Integration and end to end share the tests site, so a row that changes shared data in a separate process flushes the parent's object cache. - The end-to-end suite runs on the order tables only, as the tests site stores orders; the Integration rows cover the posts storage. - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. -- Core's comment edit screen shows author, date and status fields for an internal comment, which `Comments` keeps as stored. +- Core's comment edit screen hides an internal comment's author, status and date through a style on core's element ids, so a core release that renames them shows those fields again, and saving still keeps them as stored. - When a private comment is deleted permanently, its replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. - Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. From c17056b7e7f80a1b3531df781c052e6c2d2caec4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:40:37 +0100 Subject: [PATCH 10/19] Count only the trashed comments a user may list on the Comments screen For users without the table capability, the Comments screen leaves internal comments out of its list, but WordPress counts its views with queries of its own, so an editor saw a Trash count that included the trashed internal comments the list hid. The counts are cached for every user, by WordPress and by WooCommerce, which answers through the same filter, and the admin menu reads them before the screen exists, so the exclusion cannot sit in the count queries. AdminScreens filters wp_count_comments at priority 20, after WooCommerce: on the Comments screen, for a user without the table capability, it takes the counts answered so far, or WordPress's own, and returns a copy whose Trash count leaves out the trashed internal comments, without writing to either cache. The row now relies on the plugin's own registration, which runs in every request, clears WooCommerce's incremental counts that earlier tests leave behind, and covers both paths; it fails with the registration switched off. AGENTS.md's limitations add that a role holding the list capability without see_private can see a page run short. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 1 + .../changelog/comments-screen-trash-count | 4 ++ .../internal-comments/src/AdminScreens.php | 44 +++++++++++++++++++ .../tests/Integration/AdminScreensTest.php | 29 ++++++++---- 4 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 plugins/internal-comments/changelog/comments-screen-trash-count diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index 465aa4d..d46d3c2 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -88,6 +88,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - The end-to-end suite runs on the order tables only, as the tests site stores orders; the Integration rows cover the posts storage. - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. - Core's comment edit screen hides an internal comment's author, status and date through a style on core's element ids, so a core release that renames them shows those fields again, and saving still keeps them as stored. +- A role given the Comments screen's list capability without `see_private_dws_internal_comments` can see a page of internal comments run short of its size, because the private comments it may not see are dropped after the query, which cannot follow a private comment's chain of owners. - When a private comment is deleted permanently, its replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. - Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. diff --git a/plugins/internal-comments/changelog/comments-screen-trash-count b/plugins/internal-comments/changelog/comments-screen-trash-count new file mode 100644 index 0000000..5455f29 --- /dev/null +++ b/plugins/internal-comments/changelog/comments-screen-trash-count @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +The Comments screen's Trash count leaves out the internal comments its list hides from users who may not list them. diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index e644af7..c9a5ccd 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -52,6 +52,8 @@ public function register_hooks(): void { add_action( 'admin_menu', array( $this, 'add_comments_view' ) ); add_filter( 'admin_comment_types_dropdown', array( $this, 'add_comment_type' ) ); add_filter( 'comments_list_table_query_args', array( $this, 'filter_list_table_args' ) ); + // After WooCommerce, which answers with counts of its own at the default priority. + add_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20, 2 ); add_filter( 'the_comments', array( $this, 'filter_list_table_comments' ), 10, 2 ); add_filter( 'comment_row_actions', array( $this, 'filter_row_actions' ), \PHP_INT_MAX, 2 ); add_filter( 'bulk_actions-edit-comments', array( $this, 'filter_bulk_actions' ), \PHP_INT_MAX ); @@ -379,6 +381,48 @@ public function filter_bulk_actions( array $actions ): array { return DWS_IC_COMMENT_TYPE === $type ? \array_intersect_key( $actions, \array_flip( array( 'trash', 'untrash', 'delete' ) ) ) : $actions; } + /** + * Returns the Comments screen's counts without the trashed internal comments its list leaves out for a user who may not list them. + * + * WordPress and WooCommerce cache the counts for every user, so the plugin answers with an adjusted copy and leaves their caches alone. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param mixed $count The counts another filter answered with, or an empty array. + * @param int $post_id The post whose comments are counted, or 0 for every post. + * + * @return mixed + */ + public function count_listed_comments( mixed $count, int $post_id ): mixed { + $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( 'edit-comments' !== $screen?->id || current_user_can( DWS_IC_CAP_SEE_TABLE ) ) { + return $count; + } + + if ( ! \is_object( $count ) ) { + remove_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20 ); + try { + $count = wp_count_comments( $post_id ); + } finally { + add_filter( 'wp_count_comments', array( $this, 'count_listed_comments' ), 20, 2 ); + } + } + + $trashed = get_comments( + array( + 'post_id' => $post_id, + 'type' => DWS_IC_COMMENT_TYPE, + 'status' => 'trash', + 'count' => true, + ) + ); + $counts = \get_object_vars( $count ); + $counts['trash'] = \max( 0, ( \filter_var( $counts['trash'] ?? 0, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? 0 ) - ( \is_int( $trashed ) ? $trashed : 0 ) ); + + return (object) $counts; + } + /** * Hides the author, status and date of an internal comment on WordPress's comment edit screen, since saving keeps them as stored. * diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index b2aa869..938de6c 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -271,31 +271,42 @@ public function test_the_comment_edit_screen_hides_the_fields_an_internal_commen self::assertSame( '', $styles( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ); } - // WordPress counts the Comments screen's views apart from its list, and caches the counts for every user. + // WordPress counts the Comments screen's views apart from its list, and it and WooCommerce cache the counts for every user. public function test_the_comments_screen_counts_only_the_trashed_comments_the_user_may_list(): void { $internal = $this->comment( 'Trashed' ); $public = (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ); wp_trash_comment( $internal ); wp_trash_comment( $public ); - $screens = new AdminScreens(); - add_filter( 'wp_count_comments', array( $screens, 'count_listed_comments' ), 10, 2 ); $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. - wp_cache_delete( 'comments-0', 'counts' ); - try { + $counts = static function (): array { wp_set_current_user( self::$users['editor'] ); $editor = (int) wp_count_comments()->trash; - $cached = wp_cache_get( 'comments-0', 'counts' ); wp_set_current_user( self::$users['administrator'] ); - $administrator = (int) wp_count_comments()->trash; + + return array( $editor, (int) wp_count_comments()->trash ); + }; + $woocommerce = has_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ) ); + \WC_Comments::delete_comments_count_cache(); // Earlier tests leave WooCommerce's incremental counts behind. + + try { + list( $editor, $administrator ) = $counts(); + remove_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ) ); + wp_cache_delete( 'comments-0', 'counts' ); + list( $core_editor, $core_administrator ) = $counts(); + $cached = wp_cache_get( 'comments-0', 'counts' ); } finally { - remove_filter( 'wp_count_comments', array( $screens, 'count_listed_comments' ) ); + if ( \is_int( $woocommerce ) ) { + add_filter( 'wp_count_comments', array( 'WC_Comments', 'wp_count_comments' ), $woocommerce, 2 ); + } unset( $GLOBALS['current_screen'] ); } + self::assertIsInt( $woocommerce ); self::assertSame( $administrator - 1, $editor ); + self::assertSame( $core_administrator - 1, $core_editor ); self::assertIsObject( $cached ); - self::assertSame( $administrator, (int) $cached->trash ); + self::assertSame( $core_administrator, (int) $cached->trash ); } public function test_the_posts_list_loads_the_quick_views_assets(): void { From 901bb9d4330010b6771a77925e182af0bb8c7bcb Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:48:42 +0100 Subject: [PATCH 11/19] Read the editor's capabilities afresh in the Trash count row The Comments screen row that grants editors the table capability leaves the editor as the current user after taking the capability back from the role, and wp_set_current_user() keeps that user object for the same id, so in some random orders (seed 1793450840) the count row ran as an editor who could still list internal comments and saw no adjustment. The row reads the editor's capabilities again after switching to them. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/tests/Integration/AdminScreensTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index 938de6c..982e3da 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -281,6 +281,7 @@ public function test_the_comments_screen_counts_only_the_trashed_comments_the_us $counts = static function (): array { wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. $editor = (int) wp_count_comments()->trash; wp_set_current_user( self::$users['administrator'] ); From b97773e1576b24a98c2ed19fb4bd7fd2c94b5bf0 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:48:42 +0100 Subject: [PATCH 12/19] Pin WooCommerce's comment counts across the moves to the internal status WooCommerce answers the comment counts from a cache it adjusts on each status change, while the plugin moves comments back under the internal status, and moves v1's in bulk, with direct writes it does not announce, so the counts kept the statuses those comments passed through: a restored comment stayed in Pending, and v1's moved comments stayed in Approved, until WooCommerce's cache expired. The owner ruled that the move back be announced as a status change, and that the migration reset WooCommerce's counts after each batch. One row restores an internal comment and tries to approve it, then expects WooCommerce's cached counts to equal a fresh count; the batched-move row caches the counts before the move and expects the same after it. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/DataGuardsTest.php | 16 ++++++++++++++++ .../tests/Integration/MigrationsTest.php | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index ae9e030..bd20924 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -153,6 +153,22 @@ public function test_a_trashed_internal_comment_restores_internal(): void { self::assertSame( '', get_comment_meta( $comment, '_wp_trash_meta_status', true ) ); } + // WooCommerce counts comments from a cache it adjusts on each status change, so moving a comment back under the internal status is announced as one. + public function test_moving_a_comment_back_under_the_internal_status_keeps_woocommerces_counts(): void { + $comment = $this->comment(); + wp_trash_comment( $comment ); + \WC_Comments::delete_comments_count_cache(); + wp_count_comments(); + + wp_untrash_comment( $comment ); + wp_set_comment_status( $comment, 'approve' ); + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + + self::assertSame( DWS_IC_COMMENT_STATUS, get_comment( $comment )?->comment_approved ); + self::assertSame( (array) wp_count_comments(), $cached ); + } + public function test_a_restored_posts_internal_comments_restore_internal(): void { $comment = $this->comment(); $v1 = $this->comment(); diff --git a/plugins/internal-comments/tests/Integration/MigrationsTest.php b/plugins/internal-comments/tests/Integration/MigrationsTest.php index e7599a5..bc0506d 100644 --- a/plugins/internal-comments/tests/Integration/MigrationsTest.php +++ b/plugins/internal-comments/tests/Integration/MigrationsTest.php @@ -110,6 +110,8 @@ public function test_the_second_step_converts_v1s_internal_comments_in_batches_a clean_post_cache( $post ); self::assertInstanceOf( BatchedMigrationStepInterface::class, dws_ic_get_migration_steps()[1] ?? null ); + \WC_Comments::delete_comments_count_cache(); + wp_count_comments(); // WooCommerce caches the counts before the move, as on a store whose admin screens were open. $batches = 0; while ( dws_ic_convert_v1_comments() ) { self::assertLessThan( 10000, ++$batches ); @@ -122,6 +124,9 @@ public function test_the_second_step_converts_v1s_internal_comments_in_batches_a self::assertSame( '1', get_comment( $note )?->comment_approved ); self::assertSame( '2', get_post( $post )?->comment_count ); self::assertFalse( get_option( 'dws_ic_v1_comments_cursor' ) ); + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + self::assertSame( (array) wp_count_comments(), $cached ); wp_delete_post( $post, true ); } From 631d84aeeee16e8bb40ef15ba8f8a8dfb30ed894 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 13:53:54 +0100 Subject: [PATCH 13/19] Announce the move back to the internal status and reset Woo's counts WooCommerce answers the comment counts from a cache it adjusts on each announced status change, and the plugin moved comments back under the internal status, and v1's in bulk, without announcing it, so a restored comment stayed in Pending and v1's moved comments in Approved until WooCommerce's cache expired. Comments writes the status back through put_back(), which reports the status it replaced. After a status change, announce_kept_internal() announces the move back through wp_transition_comment_status() at the last priority of transition_comment_status, since wp_set_comment_status() announces its own change only after the action where the write-back runs; after an insert, which WordPress announces no status for, keep_inserted_internal() announces it at once. The migration resets WooCommerce's counts after each batch, as announcing up to 10,000 moves per batch would run every listener that many times. The insert path's row, added with the change, fails with its announcement off. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 2 +- .../changelog/woocommerce-comment-counts | 4 + .../internal-comments/docs/ARCHITECTURE.md | 2 +- .../internal-comments/includes/migrations.php | 4 + plugins/internal-comments/src/Comments.php | 79 ++++++++++++++++--- .../tests/Integration/DataGuardsTest.php | 10 ++- 6 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 plugins/internal-comments/changelog/woocommerce-comment-counts diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index d46d3c2..074f726 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -66,7 +66,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. A REST route declares each parameter's type and sanitizer in its `args`. 6. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-internal-comments_` rows and its comments their `dws_internal` type and `dws_ic_is_private` and `_dws_ic_` meta; anything new lives under `dws_ic_`, or `_dws_ic_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. -7. **Internal status.** An internal comment is stored under the `dws_internal` status, which keeps it out of WordPress's queries, counts, feeds and REST API without the plugin's help, so no code stores one under another status, and a query that lists them names the status. `Comments` puts back any that WordPress or another plugin moves, and until the migration has moved the comments v1 stored approved, it leaves the type out of every comment query and feed that names neither it nor the status. Every check of what a user may do with one goes through the `see_`, `edit_` and `trash_dws_internal_comment` meta capabilities. +7. **Internal status.** An internal comment is stored under the `dws_internal` status, which keeps it out of WordPress's queries, counts, feeds and REST API without the plugin's help, so no code stores one under another status, and a query that lists them names the status. `Comments` puts back any that WordPress or another plugin moves, announcing the move as a status change after WordPress announces its own, so caches that follow status changes, such as WooCommerce's comment counts, end where the comment is; the migration's bulk move resets WooCommerce's counts instead. Until the migration has moved the comments v1 stored approved, it leaves the type out of every comment query and feed that names neither it nor the status. Every check of what a user may do with one goes through the `see_`, `edit_` and `trash_dws_internal_comment` meta capabilities. 8. **Order storage.** Order code takes ids WooCommerce resolves in either storage, through `dws_ic_get_post_type()` and `edit_post`, which WooCommerce maps for orders, and WooCommerce's screen ids and list hooks, never the posts table; every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorage`. 9. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name. 10. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_ic_is_relaxed()` (the template's recipes). diff --git a/plugins/internal-comments/changelog/woocommerce-comment-counts b/plugins/internal-comments/changelog/woocommerce-comment-counts new file mode 100644 index 0000000..24d4cdc --- /dev/null +++ b/plugins/internal-comments/changelog/woocommerce-comment-counts @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +WooCommerce's comment counts stay right when an internal comment is restored, approved, held or marked spam, and after the background move of v1's comments. diff --git a/plugins/internal-comments/docs/ARCHITECTURE.md b/plugins/internal-comments/docs/ARCHITECTURE.md index 691403c..a0ef063 100644 --- a/plugins/internal-comments/docs/ARCHITECTURE.md +++ b/plugins/internal-comments/docs/ARCHITECTURE.md @@ -34,7 +34,7 @@ WooCommerce is optional: without it, every feature but the order screens and the ## Data -- **Internal comments** are comments of v1's `dws_internal` type, stored under the `dws_internal` status. WordPress's comment queries, counts, feeds and REST API include only the statuses they name, so they leave internal comments out without the plugin's help, active or not. `Comments` puts back under the status any internal comment that WordPress or another plugin stores, approves, holds, marks spam or restores under another, and keeps every field but the text as stored when one is updated. Until the migration has moved the comments v1 stored approved, it also leaves their type out of every comment query and feed that names neither the type nor the status. A post's comments on a trashed post are read from what WordPress recorded when it trashed the post. +- **Internal comments** are comments of v1's `dws_internal` type, stored under the `dws_internal` status. WordPress's comment queries, counts, feeds and REST API include only the statuses they name, so they leave internal comments out without the plugin's help, active or not. `Comments` puts back under the status any internal comment that WordPress or another plugin stores, approves, holds, marks spam or restores under another, announcing each move back through `wp_transition_comment_status()` after WordPress's own announcement so status-following caches such as WooCommerce's comment counts stay right, and keeps every field but the text as stored when one is updated. Until the migration has moved the comments v1 stored approved, it also leaves their type out of every comment query and feed that names neither the type nor the status. A post's comments on a trashed post are read from what WordPress recorded when it trashed the post. - **Comment meta** keeps v1's keys: `dws_ic_is_private`, `yes` on a comment marked private, which only its author changes; and, per change to a comment's text, `_dws_ic_change_id` with `_dws_ic_previous_content_{id}`, `_dws_ic_changed_by_{id}` and `_dws_ic_changed_time_{id}`. - **Options** keep v1's two rows, `dws-internal-comments_general` with `post-types` and `enable-private-comments`, and `dws-internal-comments_plugin` with `remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to v1's default, and applies v1's filters and then `dws_ic/settings/validated_value`. - **Migrations** are the append-only steps `dws_ic_get_migration_steps()` returns. The framework's runner runs each pending step once per site, behind a lock, and records the revision reached in `dws_ic_revision`. The first step stores v1's default rows where a site, v1 or new, has none. The second is batched, so it runs only from the runner's scheduled hook: it walks the comments table by id window, moves the internal comments v1 stored approved, pending or as spam to the status and recounts their posts, keeping its position in `dws_ic_v1_comments_cursor` until the walk ends. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. diff --git a/plugins/internal-comments/includes/migrations.php b/plugins/internal-comments/includes/migrations.php index 1efe664..3ffd1a6 100644 --- a/plugins/internal-comments/includes/migrations.php +++ b/plugins/internal-comments/includes/migrations.php @@ -106,6 +106,10 @@ function dws_ic_convert_v1_comments(): bool { foreach ( $posts as $post_id ) { wp_update_comment_count_now( $post_id ); } + // WooCommerce answers the comment counts from a cache it adjusts only on announced status changes. + if ( class_exists( 'WC_Comments' ) ) { + WC_Comments::delete_comments_count_cache(); + } } if ( $end >= $last ) { diff --git a/plugins/internal-comments/src/Comments.php b/plugins/internal-comments/src/Comments.php index 5b1f0cc..497c726 100644 --- a/plugins/internal-comments/src/Comments.php +++ b/plugins/internal-comments/src/Comments.php @@ -26,9 +26,11 @@ final class Comments implements ComponentInterface { */ #[\Override] public function register_hooks(): void { - add_action( 'wp_insert_comment', array( $this, 'keep_internal' ) ); + add_action( 'wp_insert_comment', array( $this, 'keep_inserted_internal' ) ); // Before wp_new_comment_notify_postauthor(), which wp_set_comment_status() hooks to mail an approved comment to the post's author. add_action( 'wp_set_comment_status', array( $this, 'keep_internal' ), 1 ); + // After every listener of WordPress's own announcement of the change the plugin undoes, such as WooCommerce's comment counts. + add_action( 'transition_comment_status', array( $this, 'announce_kept_internal' ), \PHP_INT_MAX, 3 ); add_action( 'untrash_comment', array( $this, 'prepare_restore' ) ); add_action( 'untrashed_post_comments', array( $this, 'keep_post_comments_internal' ) ); add_filter( 'wp_update_comment_data', array( $this, 'filter_update_data' ), \PHP_INT_MAX, 2 ); @@ -44,28 +46,58 @@ public function register_hooks(): void { // region HOOKS /** - * Puts an internal comment stored under another status that would show it, such as one imported from v1, back under the internal status. + * Puts an internal comment whose status WordPress changed back under the internal status, before WordPress announces the change. * * @since 2.0.0 * @version 2.0.0 * - * @global \wpdb $wpdb The WordPress database connection. - * * @param int|string $comment_id The comment's id. * * @return void */ public function keep_internal( int|string $comment_id ): void { - global $wpdb; + $this->put_back( $comment_id ); + } - $comment = dws_ic_get_comment( $comment_id ); - if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { + /** + * Puts an internal comment stored under another status that would show it, such as one imported from v1, back under the internal status, and announces the move as a status change, since WordPress announces none for a new comment. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int|string $comment_id The comment's id. + * + * @return void + */ + public function keep_inserted_internal( int|string $comment_id ): void { + $replaced = $this->put_back( $comment_id ); + $comment = dws_ic_get_comment( $comment_id ); + if ( null !== $replaced && null !== $comment ) { + wp_transition_comment_status( DWS_IC_COMMENT_STATUS, $replaced, $comment ); + } + } + + /** + * Announces the move of an internal comment back under the internal status after WordPress announces the change the plugin undid, so whatever follows status changes, such as WooCommerce's comment counts, ends where the comment is. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int|string $new_status The status WordPress announced. + * @param int|string $old_status The status the comment had before. + * @param mixed $comment The comment. + * + * @return void + */ + public function announce_kept_internal( int|string $new_status, int|string $old_status, mixed $comment ): void { + if ( ! $comment instanceof \WP_Comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || DWS_IC_COMMENT_STATUS === $new_status ) { return; } - $wpdb->update( $wpdb->comments, array( 'comment_approved' => DWS_IC_COMMENT_STATUS ), array( 'comment_ID' => (int) $comment->comment_ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- wp_set_comment_status() refuses a status of its own, and the cache is cleaned below. - clean_comment_cache( (int) $comment->comment_ID ); - wp_update_comment_count( (int) $comment->comment_post_ID ); + $stored = dws_ic_get_comment( (int) $comment->comment_ID ); + if ( null !== $stored && DWS_IC_COMMENT_STATUS === $stored->comment_approved ) { + wp_transition_comment_status( DWS_IC_COMMENT_STATUS, (string) $new_status, $stored ); + } } /** @@ -238,6 +270,33 @@ public function guard_privacy( mixed $check, int $comment_id, string $meta_key ) // region HELPERS + /** + * Puts an internal comment stored under a status that would show it back under the internal status, and returns the status it replaced, or null where it moved nothing. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @global \wpdb $wpdb The WordPress database connection. + * + * @param int|string $comment_id The comment's id. + * + * @return string|null + */ + protected function put_back( int|string $comment_id ): ?string { + global $wpdb; + + $comment = dws_ic_get_comment( $comment_id ); + if ( null === $comment || DWS_IC_COMMENT_TYPE !== $comment->comment_type || \in_array( $comment->comment_approved, array( DWS_IC_COMMENT_STATUS, 'trash', 'post-trashed' ), true ) ) { + return null; + } + + $wpdb->update( $wpdb->comments, array( 'comment_approved' => DWS_IC_COMMENT_STATUS ), array( 'comment_ID' => (int) $comment->comment_ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- wp_set_comment_status() refuses a status of its own, and the cache is cleaned below. + clean_comment_cache( (int) $comment->comment_ID ); + wp_update_comment_count( (int) $comment->comment_post_ID ); + + return $comment->comment_approved; + } + /** * Returns whether the migration has yet to move the comments v1 stored approved under the internal status. * diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index bd20924..ed6ed64 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -162,10 +162,18 @@ public function test_moving_a_comment_back_under_the_internal_status_keeps_wooco wp_untrash_comment( $comment ); wp_set_comment_status( $comment, 'approve' ); - $cached = (array) wp_count_comments(); + $imported = (int) wp_insert_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_type' => DWS_IC_COMMENT_TYPE, + 'comment_approved' => '1', + ) + ); + $cached = (array) wp_count_comments(); \WC_Comments::delete_comments_count_cache(); self::assertSame( DWS_IC_COMMENT_STATUS, get_comment( $comment )?->comment_approved ); + self::assertSame( DWS_IC_COMMENT_STATUS, get_comment( $imported )?->comment_approved ); self::assertSame( (array) wp_count_comments(), $cached ); } From ccb411d6d74ad5bc07a05d0b1adc6a0686b28a33 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 14:09:49 +0100 Subject: [PATCH 14/19] Pin privacy turning on only before anyone replies A reply written while its thread was public makes its author an owner of that reply's thread once the comment above it, made private later, is deleted permanently, so the author can read replies its privacy had hidden from them. The owner ruled that a comment can turn private only before anyone replies to it. The guard row expects the author and code running as no user refused when turning a replied comment private, while a private one with replies still turns public and a comment without replies turns private; the edit screen row expects the privacy choice withheld from a replied public comment and offered on a replied private one. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AdminScreensTest.php | 14 +++++++++++ .../tests/Integration/DataGuardsTest.php | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index 982e3da..3f8e451 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -246,6 +246,20 @@ public function test_the_edit_screen_of_an_internal_comment_shows_its_revisions_ self::assertStringContainsString( 'name="comment_meta[dws_ic_is_private]"', $author ); self::assertStringNotContainsString( 'name="comment_meta[dws_ic_is_private]"', $other ); self::assertSame( '', $screens->add_privacy_field( '', get_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ) ) ); + + // Once someone has replied, the author may still make a private comment public, but not the other way round. + wp_set_current_user( self::$users['administrator'] ); + $private = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); + foreach ( array( $comment, $private ) as $replied_to ) { + dws_ic_create_internal_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_parent' => $replied_to, + ) + ); + } + self::assertStringNotContainsString( 'name="comment_meta[dws_ic_is_private]"', $screens->add_privacy_field( '', get_comment( $comment ) ) ); + self::assertStringContainsString( 'name="comment_meta[dws_ic_is_private]"', $screens->add_privacy_field( '', get_comment( $private ) ) ); } // Saving keeps an internal comment's author, status and date as stored, so the edit screen does not offer them. diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index ed6ed64..369ae70 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -194,6 +194,31 @@ public function test_a_restored_posts_internal_comments_restore_internal(): void self::assertSame( '1', get_post( $this->post )?->comment_count ); } + // A reply written while its thread was open would otherwise make its author an owner of the private thread once the comment above it is deleted. + public function test_a_comment_becomes_private_only_before_anyone_replies(): void { + wp_set_current_user( $this->admin ); + $reply = fn ( int $replied_to ): int => (int) dws_ic_create_internal_comment( + array( + 'comment_post_ID' => $this->post, + 'comment_parent' => $replied_to, + ) + ); + $replied = $this->comment( 'Replied' ); + $private = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); + $alone = $this->comment( 'Alone' ); + $reply( $replied ); + $reply( $private ); + + foreach ( array( $this->admin, 0 ) as $user ) { + wp_set_current_user( $user ); + self::assertFalse( update_comment_meta( $replied, DWS_IC_META_PRIVATE, 'yes' ) ); + self::assertFalse( dws_ic_is_private_comment( $replied ) ); + } + wp_set_current_user( $this->admin ); + self::assertTrue( update_comment_meta( $private, DWS_IC_META_PRIVATE, 'no' ) ); + self::assertTrue( update_comment_meta( $alone, DWS_IC_META_PRIVATE, 'yes' ) ); + } + public function test_only_a_comments_author_changes_whether_it_is_private(): void { $comment = $this->comment( 'Private', array( DWS_IC_META_PRIVATE => 'yes' ) ); From 0ecc75b2ef66dd0ffe6ad65b78c02341c095afa6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 14:12:04 +0100 Subject: [PATCH 15/19] Let a comment turn private only before anyone replies to it A reply written while its thread was public made its author an owner of that reply's thread once the comment above it, made private later, was deleted permanently, so the author could read replies the thread's privacy had hidden from them. The privacy guard refuses turning a comment private once any internal comment replies to it, whoever asks, while a private comment with replies can still turn public; core's comment edit screen offers the choice on such a comment only while it is private. Every reply under a private comment then comes from a user who could see it, so the replies made private on a deletion give ownership only to users already in the thread. dws_ic_has_replies() counts replies of any status for both. AGENTS.md's limitation narrows to threads 1.2.4 made private after others replied. The guard row's check of a comment turning private now reads the outcome, since update_comment_meta() returns the new row's id when the key was absent. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 2 +- .../changelog/private-before-replies | 4 ++++ .../internal-comments/docs/ARCHITECTURE.md | 2 +- .../internal-comments/includes/comments.php | 23 +++++++++++++++++++ .../internal-comments/src/AdminScreens.php | 2 +- plugins/internal-comments/src/Comments.php | 21 +++++++++++++---- .../tests/Integration/DataGuardsTest.php | 3 ++- 7 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 plugins/internal-comments/changelog/private-before-replies diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index 074f726..30c544d 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -89,7 +89,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. - Core's comment edit screen hides an internal comment's author, status and date through a style on core's element ids, so a core release that renames them shows those fields again, and saving still keeps them as stored. - A role given the Comments screen's list capability without `see_private_dws_internal_comments` can see a page of internal comments run short of its size, because the private comments it may not see are dropped after the query, which cannot follow a private comment's chain of owners. -- When a private comment is deleted permanently, its replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. +- A comment turns private only before anyone replies to it, so every reply under a private comment comes from a user who could see it. A thread that 1.2.4 made private after others had replied still loses part of its privacy when its first comment is deleted permanently: the replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. - Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. - The bundled GitHub updater ships inert, since the entry file has no `Update URI`, and wordpress.org's reviewers may ask to remove it. diff --git a/plugins/internal-comments/changelog/private-before-replies b/plugins/internal-comments/changelog/private-before-replies new file mode 100644 index 0000000..ac1343c --- /dev/null +++ b/plugins/internal-comments/changelog/private-before-replies @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +A comment can be made private only before anyone replies to it; once replied to, a private comment can still be made public. diff --git a/plugins/internal-comments/docs/ARCHITECTURE.md b/plugins/internal-comments/docs/ARCHITECTURE.md index a0ef063..cbda4d3 100644 --- a/plugins/internal-comments/docs/ARCHITECTURE.md +++ b/plugins/internal-comments/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ WooCommerce is optional: without it, every feature but the order screens and the ## Data - **Internal comments** are comments of v1's `dws_internal` type, stored under the `dws_internal` status. WordPress's comment queries, counts, feeds and REST API include only the statuses they name, so they leave internal comments out without the plugin's help, active or not. `Comments` puts back under the status any internal comment that WordPress or another plugin stores, approves, holds, marks spam or restores under another, announcing each move back through `wp_transition_comment_status()` after WordPress's own announcement so status-following caches such as WooCommerce's comment counts stay right, and keeps every field but the text as stored when one is updated. Until the migration has moved the comments v1 stored approved, it also leaves their type out of every comment query and feed that names neither the type nor the status. A post's comments on a trashed post are read from what WordPress recorded when it trashed the post. -- **Comment meta** keeps v1's keys: `dws_ic_is_private`, `yes` on a comment marked private, which only its author changes; and, per change to a comment's text, `_dws_ic_change_id` with `_dws_ic_previous_content_{id}`, `_dws_ic_changed_by_{id}` and `_dws_ic_changed_time_{id}`. +- **Comment meta** keeps v1's keys: `dws_ic_is_private`, `yes` on a comment marked private, which only its author changes, and only before anyone replies when it turns private; and, per change to a comment's text, `_dws_ic_change_id` with `_dws_ic_previous_content_{id}`, `_dws_ic_changed_by_{id}` and `_dws_ic_changed_time_{id}`. - **Options** keep v1's two rows, `dws-internal-comments_general` with `post-types` and `enable-private-comments`, and `dws-internal-comments_plugin` with `remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to v1's default, and applies v1's filters and then `dws_ic/settings/validated_value`. - **Migrations** are the append-only steps `dws_ic_get_migration_steps()` returns. The framework's runner runs each pending step once per site, behind a lock, and records the revision reached in `dws_ic_revision`. The first step stores v1's default rows where a site, v1 or new, has none. The second is batched, so it runs only from the runner's scheduled hook: it walks the comments table by id window, moves the internal comments v1 stored approved, pending or as spam to the status and recounts their posts, keeping its position in `dws_ic_v1_comments_cursor` until the walk ends. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_ic_get_footprint()`, names every kind of data the plugin stores: the `dws_ic_` and `dws-internal-comments_` option prefixes, the `dws_internal` comments with their meta, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. diff --git a/plugins/internal-comments/includes/comments.php b/plugins/internal-comments/includes/comments.php index e2d9d82..fa4087a 100644 --- a/plugins/internal-comments/includes/comments.php +++ b/plugins/internal-comments/includes/comments.php @@ -311,3 +311,26 @@ function dws_ic_exclude_internal_type( $excluded ): array { function dws_ic_query_names_internal_type( array $query_vars ): bool { return in_array( DWS_IC_COMMENT_TYPE, array_merge( (array) ( $query_vars['type'] ?? array() ), (array) ( $query_vars['type__in'] ?? array() ) ), true ); } + +/** + * Returns whether any internal comment replies to a comment, whatever its status. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int $comment_id The comment's id. + * + * @return bool + */ +function dws_ic_has_replies( int $comment_id ): bool { + $replies = get_comments( + array( + 'parent' => $comment_id, + 'type' => DWS_IC_COMMENT_TYPE, + 'status' => 'any', + 'count' => true, + ) + ); + + return is_int( $replies ) && 0 < $replies; +} diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index c9a5ccd..512b978 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -527,7 +527,7 @@ public function add_privacy_field( string $html, \WP_Comment $comment ): string return $html; } - $can_choose = get_current_user_id() === (int) $comment->user_id && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) && true !== dws_ic_is_private_comment( (int) $comment->comment_parent ); + $can_choose = get_current_user_id() === (int) $comment->user_id && current_user_can( DWS_IC_CAP_INSERT_PRIVATE ) && true !== dws_ic_is_private_comment( (int) $comment->comment_parent ) && ( $is_private || ! dws_ic_has_replies( (int) $comment->comment_ID ) ); \ob_start(); ?>
diff --git a/plugins/internal-comments/src/Comments.php b/plugins/internal-comments/src/Comments.php index 497c726..7c53a0e 100644 --- a/plugins/internal-comments/src/Comments.php +++ b/plugins/internal-comments/src/Comments.php @@ -34,7 +34,7 @@ public function register_hooks(): void { add_action( 'untrash_comment', array( $this, 'prepare_restore' ) ); add_action( 'untrashed_post_comments', array( $this, 'keep_post_comments_internal' ) ); add_filter( 'wp_update_comment_data', array( $this, 'filter_update_data' ), \PHP_INT_MAX, 2 ); - add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); + add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 4 ); add_filter( 'delete_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); add_action( 'delete_comment', array( $this, 'keep_replies_private' ) ); add_action( 'pre_get_comments', array( $this, 'hide_unmoved_comments' ) ); @@ -204,7 +204,7 @@ public function keep_replies_private( int|string $comment_id ): void { update_comment_meta( (int) $reply, DWS_IC_META_PRIVATE, 'yes' ); } } finally { - add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 3 ); + add_filter( 'update_comment_metadata', array( $this, 'guard_privacy' ), 10, 4 ); } } @@ -245,7 +245,7 @@ public function hide_unmoved_feed_comments( string $where ): string { } /** - * Refuses a change to whether an internal comment is private by a signed-in user other than its author. + * Refuses a change to whether an internal comment is private by a signed-in user other than its author, and turning one private once anyone has replied to it. * * @since 2.0.0 * @version 2.0.0 @@ -253,11 +253,22 @@ public function hide_unmoved_feed_comments( string $where ): string { * @param mixed $check Null to let the change proceed, or the result that replaces it. * @param int $comment_id The comment's id. * @param string $meta_key The meta key changed. + * @param mixed $meta_value The value written, or null where the meta is deleted. * * @return mixed */ - public function guard_privacy( mixed $check, int $comment_id, string $meta_key ): mixed { - if ( DWS_IC_META_PRIVATE !== $meta_key || 0 === get_current_user_id() ) { + public function guard_privacy( mixed $check, int $comment_id, string $meta_key, mixed $meta_value = null ): mixed { + if ( DWS_IC_META_PRIVATE !== $meta_key ) { + return $check; + } + + // A comment turns private only before anyone replies, since a reply's author would otherwise own the private thread once the comment above is deleted. + $turns_private = true === filter_var( $meta_value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) && true !== filter_var( get_comment_meta( $comment_id, DWS_IC_META_PRIVATE, true ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ); + if ( $turns_private && dws_ic_has_replies( $comment_id ) ) { + return false; + } + + if ( 0 === get_current_user_id() ) { return $check; } diff --git a/plugins/internal-comments/tests/Integration/DataGuardsTest.php b/plugins/internal-comments/tests/Integration/DataGuardsTest.php index 369ae70..cd05298 100644 --- a/plugins/internal-comments/tests/Integration/DataGuardsTest.php +++ b/plugins/internal-comments/tests/Integration/DataGuardsTest.php @@ -216,7 +216,8 @@ public function test_a_comment_becomes_private_only_before_anyone_replies(): voi } wp_set_current_user( $this->admin ); self::assertTrue( update_comment_meta( $private, DWS_IC_META_PRIVATE, 'no' ) ); - self::assertTrue( update_comment_meta( $alone, DWS_IC_META_PRIVATE, 'yes' ) ); + self::assertNotFalse( update_comment_meta( $alone, DWS_IC_META_PRIVATE, 'yes' ) ); + self::assertTrue( dws_ic_is_private_comment( $alone ) ); } public function test_only_a_comments_author_changes_whether_it_is_private(): void { From 5175fdf21dacedc82bdb792d169421295dd56789 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 16:17:30 +0100 Subject: [PATCH 16/19] Pin the counts across the pending window of v1's move Claude's review of the rulings branch found that while v1's comments are being moved, the type exclusion already leaves internal comments out of the counts core and WooCommerce compute, so the Comments screen's adjustment took the trashed ones out a second time; and that the counts cached during that window outlived the move, as only a batch that moved comments reset WooCommerce's, never the walk's end. One row expects an editor's Trash count equal to an administrator's while the move is pending; the other caches the counts while it is pending, walks to the end, and expects them equal to a fresh count. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AdminScreensTest.php | 27 +++++++++++++++ .../tests/Integration/MigrationsTest.php | 33 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index 3f8e451..66fea9d 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -324,6 +324,33 @@ public function test_the_comments_screen_counts_only_the_trashed_comments_the_us self::assertSame( $core_administrator, (int) $cached->trash ); } + // While v1's comments are being moved, the type exclusion already leaves internal comments out of every count. + public function test_the_comments_screen_counts_trashed_comments_once_while_v1s_are_moved(): void { + wp_trash_comment( $this->comment( 'Trashed' ) ); + wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); + $revision = get_option( 'dws_ic_revision' ); + update_option( 'dws_ic_revision', 1 ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_set_current_user( self::$users['administrator'] ); + $administrator = (int) wp_count_comments()->trash; + wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. + $editor = (int) wp_count_comments()->trash; + } finally { + update_option( 'dws_ic_revision', $revision ); + unset( $GLOBALS['current_screen'] ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + } + + self::assertGreaterThan( 0, $administrator ); + self::assertSame( $administrator, $editor ); + } + public function test_the_posts_list_loads_the_quick_views_assets(): void { ( new AdminScreens() )->enqueue_list_assets( 'edit.php' ); diff --git a/plugins/internal-comments/tests/Integration/MigrationsTest.php b/plugins/internal-comments/tests/Integration/MigrationsTest.php index bc0506d..b36f56d 100644 --- a/plugins/internal-comments/tests/Integration/MigrationsTest.php +++ b/plugins/internal-comments/tests/Integration/MigrationsTest.php @@ -76,6 +76,39 @@ public function test_the_first_step_throws_when_a_default_row_cannot_be_stored() } } + // The type exclusion leaves internal comments out of the counts cached while the move is pending, so its end has them counted afresh. + public function test_the_end_of_the_move_has_the_comment_counts_counted_afresh(): void { + $post = (int) wp_insert_post( + array( + 'post_title' => 'Counted', + 'post_status' => 'publish', + ) + ); + $trashed = (int) dws_ic_create_internal_comment( array( 'comment_post_ID' => $post ) ); + wp_trash_comment( $trashed ); + $revision = get_option( 'dws_ic_revision' ); + update_option( 'dws_ic_revision', 1 ); + delete_option( 'dws_ic_v1_comments_cursor' ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_count_comments(); + while ( dws_ic_convert_v1_comments() ) { + continue; + } + } finally { + update_option( 'dws_ic_revision', $revision ); + } + $cached = (array) wp_count_comments(); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + $fresh = (array) wp_count_comments(); + wp_delete_post( $post, true ); + + self::assertSame( $fresh, $cached ); + } + public function test_the_second_step_converts_v1s_internal_comments_in_batches_and_recounts_their_posts(): void { global $wpdb; $post = (int) wp_insert_post( From b9de4e4c922f993023211f502b932380358fe21a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 16:18:34 +0100 Subject: [PATCH 17/19] Count once while v1's move is pending and recount at its end Claude's review of the rulings branch found that while v1's comments are being moved, the type exclusion already leaves internal comments out of every count, so the Comments screen's adjustment took the trashed ones out a second time; and that counts cached in that window outlived the move, as only a batch that moved comments reset WooCommerce's. dws_ic_is_v1_move_pending() is the one home of the pending check, which Comments' exclusion and the count adjustment share; the adjustment steps aside while the move is pending. The migration clears core's global counts and WooCommerce's after a batch that moved comments and at the walk's end. The review's cleanups go with it: keep_inserted_internal() fetches the comment only when it moved one, and keep_post_comments_internal() calls put_back() directly. AGENTS.md's limitations name the posts a listing role cannot edit beside private comments, with view counts, and add_comment_meta() passing past both privacy rules. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/internal-comments/AGENTS.md | 4 +-- .../internal-comments/includes/migrations.php | 19 ++++++++++++- .../internal-comments/src/AdminScreens.php | 3 ++- plugins/internal-comments/src/Comments.php | 27 +++++++------------ 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/plugins/internal-comments/AGENTS.md b/plugins/internal-comments/AGENTS.md index 30c544d..96df771 100644 --- a/plugins/internal-comments/AGENTS.md +++ b/plugins/internal-comments/AGENTS.md @@ -88,8 +88,8 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run - The end-to-end suite runs on the order tables only, as the tests site stores orders; the Integration rows cover the posts storage. - A site that returns to 1.2.4 after the upgrade stores its new internal comments approved, and the migration that moved v1's comments does not run again on the next upgrade, so those comments stay public until moved by hand. - Core's comment edit screen hides an internal comment's author, status and date through a style on core's element ids, so a core release that renames them shows those fields again, and saving still keeps them as stored. -- A role given the Comments screen's list capability without `see_private_dws_internal_comments` can see a page of internal comments run short of its size, because the private comments it may not see are dropped after the query, which cannot follow a private comment's chain of owners. -- A comment turns private only before anyone replies to it, so every reply under a private comment comes from a user who could see it. A thread that 1.2.4 made private after others had replied still loses part of its privacy when its first comment is deleted permanently: the replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. +- A role given the Comments screen's list capability without `see_private_dws_internal_comments`, or without access to edit every post, can see a page of internal comments run short of its size, and view counts above the rows listed, because the comments it may not see are dropped after the query, which cannot follow a private comment's chain of owners. +- A comment turns private only before anyone replies to it, so every reply under a private comment comes from a user who could see it; code that adds the flag with `add_comment_meta()` where none is stored passes neither this rule nor the author rule, which keeps imports of private comments working. A thread that 1.2.4 made private after others had replied still loses part of its privacy when its first comment is deleted permanently: the replies become private under their own authors, who then also see the replies beneath them that the deleted comment's privacy hid from them. - Core's comment edit screen opens for an internal comment only to a user who can both edit and trash it, because core trashes, deletes, restores and moderates on the same `edit_comment` check; a role that can only edit edits in the meta box. - The release zip carries the TypeScript and SCSS sources beside the built files. - The bundled GitHub updater ships inert, since the entry file has no `Update URI`, and wordpress.org's reviewers may ask to remove it. diff --git a/plugins/internal-comments/includes/migrations.php b/plugins/internal-comments/includes/migrations.php index 3ffd1a6..49c1bed 100644 --- a/plugins/internal-comments/includes/migrations.php +++ b/plugins/internal-comments/includes/migrations.php @@ -9,6 +9,7 @@ */ use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; +use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; defined( 'ABSPATH' ) || exit; @@ -22,6 +23,18 @@ */ const DWS_IC_REVISION_V1_COMMENTS_MOVED = 2; +/** + * Returns whether the migration has yet to move the comments v1 stored approved under the internal status. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return bool + */ +function dws_ic_is_v1_move_pending(): bool { + return MigrationRunner::get_revision( 'dws_ic_revision' ) < DWS_IC_REVISION_V1_COMMENTS_MOVED; +} + /** * Returns the migration steps, append-only, where index 0 brings the data to revision 1. * @@ -106,7 +119,11 @@ function dws_ic_convert_v1_comments(): bool { foreach ( $posts as $post_id ) { wp_update_comment_count_now( $post_id ); } - // WooCommerce answers the comment counts from a cache it adjusts only on announced status changes. + } + + // Counts cached while the move is pending leave internal comments out, and WooCommerce adjusts its own only on announced status changes. + if ( array() !== $ids || $end >= $last ) { + wp_cache_delete( 'comments-0', 'counts' ); if ( class_exists( 'WC_Comments' ) ) { WC_Comments::delete_comments_count_cache(); } diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index 512b978..357b81c 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -396,7 +396,8 @@ public function filter_bulk_actions( array $actions ): array { */ public function count_listed_comments( mixed $count, int $post_id ): mixed { $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( 'edit-comments' !== $screen?->id || current_user_can( DWS_IC_CAP_SEE_TABLE ) ) { + // While v1's comments are being moved, every count already leaves the internal type out. + if ( 'edit-comments' !== $screen?->id || current_user_can( DWS_IC_CAP_SEE_TABLE ) || dws_ic_is_v1_move_pending() ) { return $count; } diff --git a/plugins/internal-comments/src/Comments.php b/plugins/internal-comments/src/Comments.php index 7c53a0e..ce862f1 100644 --- a/plugins/internal-comments/src/Comments.php +++ b/plugins/internal-comments/src/Comments.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\InternalComments; use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; -use DeepWebSolutions\InternalComments\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; \defined( 'ABSPATH' ) || exit; @@ -71,8 +70,12 @@ public function keep_internal( int|string $comment_id ): void { */ public function keep_inserted_internal( int|string $comment_id ): void { $replaced = $this->put_back( $comment_id ); - $comment = dws_ic_get_comment( $comment_id ); - if ( null !== $replaced && null !== $comment ) { + if ( null === $replaced ) { + return; + } + + $comment = dws_ic_get_comment( $comment_id ); + if ( null !== $comment ) { wp_transition_comment_status( DWS_IC_COMMENT_STATUS, $replaced, $comment ); } } @@ -136,7 +139,7 @@ public function keep_post_comments_internal( int $post_id ): void { ) ); foreach ( \is_array( $comments ) ? $comments : array() as $comment_id ) { - $this->keep_internal( $comment_id ); + $this->put_back( $comment_id ); } } @@ -219,7 +222,7 @@ public function keep_replies_private( int|string $comment_id ): void { * @return void */ public function hide_unmoved_comments( \WP_Comment_Query $query ): void { - if ( ! $this->is_move_pending() || dws_ic_query_names_internal_type( $query->query_vars ) || \in_array( DWS_IC_COMMENT_STATUS, (array) ( $query->query_vars['status'] ?? array() ), true ) ) { + if ( ! dws_ic_is_v1_move_pending() || dws_ic_query_names_internal_type( $query->query_vars ) || \in_array( DWS_IC_COMMENT_STATUS, (array) ( $query->query_vars['status'] ?? array() ), true ) ) { return; } @@ -241,7 +244,7 @@ public function hide_unmoved_comments( \WP_Comment_Query $query ): void { public function hide_unmoved_feed_comments( string $where ): string { global $wpdb; - return $this->is_move_pending() ? $where . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ) : $where; + return dws_ic_is_v1_move_pending() ? $where . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ) : $where; } /** @@ -308,17 +311,5 @@ protected function put_back( int|string $comment_id ): ?string { return $comment->comment_approved; } - /** - * Returns whether the migration has yet to move the comments v1 stored approved under the internal status. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @return bool - */ - protected function is_move_pending(): bool { - return MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) < DWS_IC_REVISION_V1_COMMENTS_MOVED; - } - // endregion } From 9debe10c952f0ac953d3bd872603aba3a25e3a34 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 16:21:26 +0100 Subject: [PATCH 18/19] Pin the Trash count to the comments WooCommerce lists Codex's first round on the rulings branch found that WooCommerce leaves comments on products out of every comment query that names no type, the counts included, but skips that exclusion for a query that names one, so the Trash adjustment, which counted the trashed internal comments by naming their type, also took out the ones on products the count never held. The row trashes an internal comment on a product and a public one on a post, and expects an editor's Trash count equal to an administrator's. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AdminScreensTest.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/plugins/internal-comments/tests/Integration/AdminScreensTest.php b/plugins/internal-comments/tests/Integration/AdminScreensTest.php index 66fea9d..b48e65a 100644 --- a/plugins/internal-comments/tests/Integration/AdminScreensTest.php +++ b/plugins/internal-comments/tests/Integration/AdminScreensTest.php @@ -324,6 +324,38 @@ public function test_the_comments_screen_counts_only_the_trashed_comments_the_us self::assertSame( $core_administrator, (int) $cached->trash ); } + // WooCommerce leaves comments on products out of every comment query that names no type, the counts included. + public function test_the_comments_screen_counts_trashed_comments_as_woocommerce_lists_them(): void { + $product = (int) wp_insert_post( + array( + 'post_type' => 'product', + 'post_title' => 'Product', + 'post_status' => 'publish', + ) + ); + wp_trash_comment( (int) dws_ic_create_internal_comment( array( 'comment_post_ID' => $product ), get_user_by( 'id', self::$users['administrator'] ) ?: null ) ); + wp_trash_comment( (int) wp_insert_comment( array( 'comment_post_ID' => $this->post ) ) ); + $GLOBALS['current_screen'] = \WP_Screen::get( 'edit-comments' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Sets the screen without firing current_screen, which other plugins hook with admin-only code. + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + + try { + wp_set_current_user( self::$users['administrator'] ); + $administrator = (int) wp_count_comments()->trash; + wp_set_current_user( self::$users['editor'] ); + wp_get_current_user()->get_role_caps(); // An earlier row can leave the editor current with capabilities its role has since lost. + $editor = (int) wp_count_comments()->trash; + } finally { + unset( $GLOBALS['current_screen'] ); + wp_delete_post( $product, true ); + \WC_Comments::delete_comments_count_cache(); + wp_cache_delete( 'comments-0', 'counts' ); + } + + self::assertGreaterThan( 0, $administrator ); + self::assertSame( $administrator, $editor ); + } + // While v1's comments are being moved, the type exclusion already leaves internal comments out of every count. public function test_the_comments_screen_counts_trashed_comments_once_while_v1s_are_moved(): void { wp_trash_comment( $this->comment( 'Trashed' ) ); From 4a4a24c9648686b855ddd70681c3c6b1059b0606 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sat, 31 Oct 2026 16:22:22 +0100 Subject: [PATCH 19/19] Count the listed Trash through the same query shape as the count WooCommerce leaves comments on products out of every comment query that names no type, and skips that exclusion for one that names a type, so subtracting the trashed internal comments, counted by naming their type, also took out the ones on products the count never held. The adjustment counts the Trash again with no type named and the internal type left out through a comments_clauses filter it adds for that query alone, so WooCommerce's exclusion, and any other plugin's on such queries, applies to it as it did to the count it replaces. Assisted-by: Claude Code:claude-opus-5-5 --- .../internal-comments/src/AdminScreens.php | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/plugins/internal-comments/src/AdminScreens.php b/plugins/internal-comments/src/AdminScreens.php index 357b81c..53c47c7 100644 --- a/plugins/internal-comments/src/AdminScreens.php +++ b/plugins/internal-comments/src/AdminScreens.php @@ -382,7 +382,7 @@ public function filter_bulk_actions( array $actions ): array { } /** - * Returns the Comments screen's counts without the trashed internal comments its list leaves out for a user who may not list them. + * Returns the Comments screen's counts with a Trash count that leaves out the trashed internal comments its list leaves out for a user who may not list them. * * WordPress and WooCommerce cache the counts for every user, so the plugin answers with an adjusted copy and leaves their caches alone. * @@ -410,20 +410,46 @@ public function count_listed_comments( mixed $count, int $post_id ): mixed { } } - $trashed = get_comments( - array( - 'post_id' => $post_id, - 'type' => DWS_IC_COMMENT_TYPE, - 'status' => 'trash', - 'count' => true, - ) - ); + // The count names no type, so the exclusions other plugins apply to such queries, as WooCommerce's of comments on products, apply to it as they did to the count it replaces. + add_filter( 'comments_clauses', array( $this, 'leave_out_internal_type' ) ); + try { + $listed = get_comments( + array( + 'post_id' => $post_id, + 'status' => 'trash', + 'count' => true, + ) + ); + } finally { + remove_filter( 'comments_clauses', array( $this, 'leave_out_internal_type' ) ); + } + $counts = \get_object_vars( $count ); - $counts['trash'] = \max( 0, ( \filter_var( $counts['trash'] ?? 0, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? 0 ) - ( \is_int( $trashed ) ? $trashed : 0 ) ); + $counts['trash'] = \is_int( $listed ) ? $listed : 0; return (object) $counts; } + /** + * Returns a comment query's clauses with the internal type left out of its WHERE clause, for a count that must name no type. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @global \wpdb $wpdb The WordPress database connection. + * + * @param array $clauses The query's clauses. + * + * @return array + */ + public function leave_out_internal_type( array $clauses ): array { + global $wpdb; + + $clauses['where'] = ( $clauses['where'] ?? '' ) . ( $wpdb->prepare( ' AND %i.comment_type <> %s', $wpdb->comments, DWS_IC_COMMENT_TYPE ) ?? '' ); + + return $clauses; + } + /** * Hides the author, status and date of an internal comment on WordPress's comment edit screen, since saving keeps them as stored. *