Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f8447c8
Warn in the Upgrade Notice that 1.2.4's comments stay public
ahegyes Oct 31, 2026
2807255
Pin a refused private comment's message to say it was not posted
ahegyes Oct 31, 2026
99eab41
Say why a refused private comment was not posted
ahegyes Oct 31, 2026
e513da3
Pin core's edit_comment needing both capabilities on internal comments
ahegyes Oct 31, 2026
bdc2118
Ask for both capabilities when core checks edit_comment
ahegyes Oct 31, 2026
0e47c87
Pin the comment edit screen hiding what an internal comment keeps
ahegyes Oct 31, 2026
e5cf5e1
Hide what an internal comment keeps on the comment edit screen
ahegyes Oct 31, 2026
a2bb34a
Pin the Comments screen's Trash count to the comments a user may list
ahegyes Oct 31, 2026
51a3ff7
Describe the comment edit screen's hidden fields in the limitations
ahegyes Oct 31, 2026
c17056b
Count only the trashed comments a user may list on the Comments screen
ahegyes Oct 31, 2026
901bb9d
Read the editor's capabilities afresh in the Trash count row
ahegyes Oct 31, 2026
b97773e
Pin WooCommerce's comment counts across the moves to the internal status
ahegyes Oct 31, 2026
631d84a
Announce the move back to the internal status and reset Woo's counts
ahegyes Oct 31, 2026
ccb411d
Pin privacy turning on only before anyone replies
ahegyes Oct 31, 2026
0ecc75b
Let a comment turn private only before anyone replies to it
ahegyes Oct 31, 2026
5175fdf
Pin the counts across the pending window of v1's move
ahegyes Oct 31, 2026
b9de4e4
Count once while v1's move is pending and recount at its end
ahegyes Oct 31, 2026
9debe10
Pin the Trash count to the comments WooCommerce lists
ahegyes Oct 31, 2026
4a4a24c
Count the listed Trash through the same query shape as the count
ahegyes Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions plugins/internal-comments/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -87,8 +87,9 @@ 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.
- 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 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`, 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.
4 changes: 4 additions & 0 deletions plugins/internal-comments/changelog/comment-edit-screen
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions plugins/internal-comments/changelog/core-comment-actions
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions plugins/internal-comments/changelog/private-before-replies
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions plugins/internal-comments/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ 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.
- **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}`.
- **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 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.
Expand Down
23 changes: 23 additions & 0 deletions plugins/internal-comments/includes/comments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
21 changes: 21 additions & 0 deletions plugins/internal-comments/includes/migrations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
*
Expand Down Expand Up @@ -108,6 +121,14 @@ function dws_ic_convert_v1_comments(): bool {
}
}

// 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();
}
}

if ( $end >= $last ) {
delete_option( 'dws_ic_v1_comments_cursor' );

Expand Down
2 changes: 1 addition & 1 deletion plugins/internal-comments/readme.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
=== Internal Comments ===

Check notice on line 1 in plugins/internal-comments/readme.txt

View workflow job for this annotation

GitHub Actions / readme.txt lint (internal-comments)

[donate-link] Consider adding a donate link to support the plugin
Contributors: ahegyes, deepwebsolutions
Tags: internal comments, private comments
Tested up to: 7.1
Expand Down Expand Up @@ -81,7 +81,7 @@
== 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 ==

Expand Down
96 changes: 95 additions & 1 deletion plugins/internal-comments/src/AdminScreens.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ 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' ) );
// 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 );
Expand Down Expand Up @@ -378,6 +381,97 @@ 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 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.
*
* @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;
// 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;
}

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 );
}
}

// 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'] = \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<string, string> $clauses The query's clauses.
*
* @return array<string, string>
*/
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.
*
* @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.
*
Expand Down Expand Up @@ -460,7 +554,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();
?>
<div class="misc-pub-section misc-pub-dws-ic-private">
Expand Down
Loading
Loading