Skip to content

Port Internal Comments to the v2 framework - #22

Merged
ahegyes merged 43 commits into
trunkfrom
feat/ic-port
Oct 31, 2026
Merged

ahegyes merged 43 commits into
trunkfrom
feat/ic-port

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Ports Internal Comments 1.2.4 to the v2 framework as 2.0.0, on the post types a site selects, WooCommerce orders included in either storage, distributed through wordpress.org, and adds it to the monorepo's fan-outs and shared test environment.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Build / CI / tooling
  • Documentation
  • Dependency update

Checklist

  • Changelog fragment added (composer changelog:add in the changed plugin's directory, or at the root for the monorepo itself)
  • Tests added/updated
  • composer quality-check passes locally in every changed plugin
  • Plugin header and readme.txt updated if runtime requirements changed

What changes

  • Root: Internal Comments joins the root's fan-outs and the shared tests environment beside Linked Orders, with an end-to-end row on an order screen both plugins use.
  • Storage: internal comments keep v1's dws_internal type and are stored under a dws_internal status, so WordPress's queries, counts, feeds and REST API leave them out even while the plugin is inactive. A background migration moves v1's comments; until it finishes, their type keeps them out of comment queries and feeds. The plugin puts the status back whenever WordPress or another plugin changes it.
  • Access: v1's twelve capabilities and their grants stay; seeing, editing and trashing an internal comment also needs access to edit its post. Core's REST API, XML-RPC and the Comments screen's reply action are held to the plugin's capabilities.
  • Screens: the meta box works through REST routes under dws_ic/v1 with a script of its own, by mouse or keyboard, announcing each outcome; the posts list gains a column with a quick view; the Comments screen gains a view; the comment edit screen lists revisions and lets the author change privacy. Orders work on the posts storage and the order tables, and the plugin declares compatibility with them.
  • Compatibility: every renamed hook fires its v1 name first, deprecated; v1's comment API, model, root functions, constants and boot hooks stay.

Breaking changes

  • Requires PHP 8.5 and WordPress 7.1; WooCommerce 11.2 where orders are used.
  • Internal comments move to a status of their own, so going back to 1.2.4 hides them.
  • Seeing, editing and trashing an internal comment also needs access to edit its post.
  • Hooks move to dws_ic/ names; their v1 names still fire, deprecated, except the settings, query, output and v1 framework notice hooks, which are removed.
  • v1's AJAX actions, jQuery meta box script and its JavaScript hooks, container functions, lifecycle functions, dws_ic_include_view() and Freemius are removed.
  • A comment marked private where the site disallows private comments is refused instead of posted for every reader.

Not verified locally

  • Plugin Check, the readme linter and the release rehearsal run only in CI.
  • The wp-internal-comments mirror needs a wp-org-release environment with SVN_USERNAME and SVN_PASSWORD before the first wordpress.org release.

Refreshes the root's and Linked Orders' Composer locks, which move only
roave/security-advisories and the mirrors' metadata, and Linked Orders'
npm lock, which takes webpack 5.112.2 and its patch-level neighbours.
The root's npm lock stays as it was, because the host's seven-day
release cooldown would have moved one Babel helper back from 7.29.10 to
7.29.7. No code change was needed.

Assisted-by: Claude Code:claude-opus-5-5
Internal Comments is the second plugin ported to v2. It starts from
wordpress-plugin-template's trunk (adfa59e) through the template's own
fill-in, run in a clone of the template: name Internal Comments, slug
internal-comments, namespace DeepWebSolutions\InternalComments, prefix
dws_ic, ports 8824 and 8825, repository ahegyes/wp-internal-comments.

As the v1 successor it stays at 2.0.0, which the fill-in sets to 0.1.0
for a new plugin, and its Composer name is its mirror,
ahegyes/wp-internal-comments. The directory keeps the template's files
but for CONTRIBUTING.md, the pull-request and issue templates,
Dependabot's configuration and the fill-in's workflow, script and
Quality job, which belong to the monorepo. The template's demo stays
until the port replaces it, and every suite passes as filled in.

Assisted-by: Claude Code:claude-opus-5-5
…omments

Internal Comments carried the template's demo, which a plugin replaces,
and the template's WooCommerce-first stack, which it does not need:
its comments work on any post type, and orders are one of them.

The greeting feature, the example block, the editor script and the
WooCommerce settings tab go with their options, fields, tests and
build entries. Following the template's "Without WooCommerce" recipe,
the framework's woocommerce package, the Requires Plugins header and
the WooCommerce boot check go, and the plugin logs through core's
ErrorLogLogger. WooCommerce and its stubs stay as development
dependencies and the WC headers and the order-tables declaration stay,
because the port supports orders through an optional integration.

The build now produces v1's meta box stylesheet, which the entry's
build gate checks. The data consent and the template's generic
readers stay until the port replaces them, and the end-to-end check
without WooCommerce now expects the plugin to boot.

Assisted-by: Claude Code:claude-opus-5-5
The root's shared environment, its Integration run and its plugins:*
fan-outs named Linked Orders alone, so Internal Comments would have
joined the CI lanes, which find plugins on their own, but not the
shared site where the plugins are tested side by side.

The shared environment maps and activates Internal Comments beside
Linked Orders, test:integration:run runs its Integration suite there
after Linked Orders', and every Composer and npm fan-out lists it. The
monorepo AGENTS.md names the plugin and its ports, and its limitation
now says that each plugin's suite runs twice.

Assisted-by: Claude Code:claude-opus-5-5
The port keeps v1's settings, capabilities and stored data as its
contract, so these rows pin them before the code that serves them.

They expect v1's two grouped option rows and their fields with v1's
defaults; v1's settings API, which reads a field by group or through
the section and fires v1's filters only where v1 fired them before
the v2 filter; the options page under Settings at v1's slug, tabs and
capability, whose cleared post types store an empty selection; v1's
twelve capabilities granted to the roles v1 granted them, private
comments' two included; a first migration that keeps a v1 site's rows;
and an uninstall that reads v1's consent and removes the internal
comments, v1's leftover rows and the capabilities, and keeps them
without consent.

Assisted-by: Claude Code:claude-opus-5-5
The partial-deploy rows still named the template's flat consent
option, which the port replaces with v1's plugin settings row, and the
peer-getters row hid src/Plugin.php to throw from options.php, which
no longer reads it at include time.

The uninstall row consents through the plugin row's
remove-data-uninstall field, and the peer-getters row throws from an
include of its own that loads after metadata.php, as Linked Orders'
row does.

Assisted-by: Claude Code:claude-opus-5-5
Internal Comments kept its settings in two grouped rows behind v1's
options page, granted its capabilities through v1's framework and
removed its data only through Freemius' uninstall hook, none of which
the v2 plugin has yet.

The settings page is the framework's OptionsPage under Settings, at
v1's slug, tabs and capability, storing each tab in v1's row; its
checkbox list posts an empty value, so clearing every post type now
saves an empty selection. Each group's fields pass v1's group filter,
deprecated, then dws_ic/settings/{group}_fields. v1's settings API
keeps its names and reads a field by group or through the page, firing
v1's filters only where v1 fired them before
dws_ic/settings/validated_value. The capabilities keep v1's names and
grants, private comments' two included, through CapabilityGrants. The
first migration stores v1's default rows where a site has none, and
uninstall reads v1's consent and removes the internal comments, the
rows, v1's leftover rows and the capabilities. The plugin row links to
the settings page and the support forum.

Assisted-by: Claude Code:claude-opus-5-5
The port moves internal comments to a status of their own, so that
WordPress leaves them out of every query, count, feed and REST read
even while the plugin is inactive; these rows pin that and v1's
comment API before the code that serves them.

They expect v1's functions and model, with v1's creation filter
firing deprecated before the v2 filter; v1's three meta capabilities,
which also need access to edit the comment's post and, for a private
thread, its author or the private capability, and which core's
edit_comment checks follow; the status kept internal through status
changes, updates, imports, trash and restore, without notifying the
post's author; a revision of each content change; privacy changed
only by the comment's author; reads in a request without the plugin
that leave internal comments out; and a batched step that converts
v1's approved and pending rows and recounts their posts.

Assisted-by: Claude Code:claude-opus-5-5
v1 stored internal comments approved and hid them with query, count
and feed filters, so they showed wherever those filters did not run:
with the plugin inactive, in REST reads and in post comment counts.
Approving one from the Comments screen or the REST API published it,
and its meta capabilities ignored the post the comment was on.

Internal comments keep v1's type under a status of their own, which
WordPress's queries, counts, feeds and REST API leave out unless they
name it. A Comments component puts a comment stored, approved, held,
marked spam or restored under another status back under it, before
WordPress mails the post's author, keeps every field but the content
as stored on an update and records the content each update replaces,
and lets only a comment's author change whether it is private. A
Permissions component maps v1's three meta capabilities, and core's
edit_comment on an internal comment, to v1's capabilities, each also
needing access to edit the post and, on a private thread, its owners
or the private capability. v1's comment API and model keep their
names. A batched second migration step walks the comments table by id
window, moving v1's rows to the status and recounting their posts.

The Integration bootstrap runs the runner's scheduled hook, as the
site's scheduler would, so the suite starts at the code's revision.
Two rows expected the wrong value: v1's creation filter keeps its own
content under the array union, and the plugin-less REST list passed
its post as an attribute rather than a query parameter.

Assisted-by: Claude Code:claude-opus-5-5
The meta box is the plugin's main surface, and the port replaces v1's
admin-ajax actions with REST routes that answer with the list rendered
on the server; these rows pin both before the code that serves them.

They expect posting, replying, reading, editing and trashing through
dws_ic/v1, each refused without the capability, access to the post or
a selected post type, with v1's AJAX hooks firing deprecated before
their v2 names and private comments only where the site allows them;
a list that holds only the comments its reader can see, a private
reply left out of its thread included; and a meta box on selected post
types for users with the capability, holding the forms they may use,
a status region, and v1's markup hooks firing before their v2 names.

Assisted-by: Claude Code:claude-opus-5-5
v1 rendered its meta box through a framework settings group and drove
it through six admin-ajax actions, which checked a nonce and a
capability but not the user's access to the post, and printed every
reply of a thread to whoever could see its first comment.

The meta box is a classic meta box on the selected post types, for
users with v1's capability who can edit the post. Its markup comes
from functions in includes/markup.php, which the dws_ic/v1 REST routes
also answer with, so the script re-renders the list the server
returns; each reply prints only for a reader who can see it, and v1's
markup hooks fire deprecated before their v2 names. The routes post,
read, edit and trash a comment behind WordPress's REST nonce, each
checking the capability, access to the post and a selected post type,
and fire v1's AJAX hooks deprecated before their v2 names. Its forms
are groups of fields whose buttons are plain buttons, because the edit
screen already wraps every meta box in the post's form.

The rows that expect a deprecated hook listen on it, because WordPress
reports one only then; the asset row calls the meta box directly, as
firing admin_enqueue_scripts runs WooCommerce's admin callbacks outside
an admin request; and the editor row expects v1's private grant.

Assisted-by: Claude Code:claude-opus-5-5
The meta box's script posts, replies to, edits and trashes comments in
place, which only a browser exercises.

The spec opens a post in the block editor, unfolds its meta box pane,
posts a comment, replies to it, edits it by keyboard and trashes the
reply, reading each outcome from the box's status region, then checks
the box with axe; a second row expects an empty comment refused with a
message and the focus back on its field.

Assisted-by: Claude Code:claude-opus-5-5
The meta box printed its forms but nothing acted on them, and v1's
script needed jQuery, wp.template and localized variables, and offered
in-place editing and replies only to its premium build.

A TypeScript script, built by wp-scripts with @wordpress/api-fetch and
@wordpress/i18n as externals, posts, replies to, edits and trashes
comments through the dws_ic/v1 routes and replaces the list with the
one each answers with. It refuses an empty comment, opens one edit or
reply form at a time from the templates the meta box prints, takes
Ctrl or Cmd and Enter to save and Escape to cancel, returns the focus
to the control that opened a form, and announces each outcome in a
status region that is also visible. A permanent deletion, where the
site keeps no trash, asks first. The stylesheet gains the plain-button
actions, a private badge and text colours that meet WCAG AA contrast,
and a declaration lets TypeScript accept the stylesheet's import.

Assisted-by: Claude Code:claude-opus-5-5
Beyond the meta box, v1 showed internal comments in a posts list
column, under their own view of the Comments screen and on core's
comment edit screen; these rows pin those surfaces before the code.

They expect the column after the comments column, placed through v1's
filter, deprecated, then dws_ic/list_table/preceding_column, counting
the comments its reader can see and opening a quick view; the Comments
screen listing internal comments under their type only for users with
the list's capability, leaving them out of every view for the rest,
trash included, and offering only the actions they support; and the
edit screen showing a comment's revisions and letting its author alone
change whether it is private.

Assisted-by: Claude Code:claude-opus-5-5
The Comments screen lists comments without asking whether its reader
may see each one, so a role with the list's capability but not the
private one would read private comments there; and the edit screen
offers the privacy toggle only where private comments are allowed.

One row expects the screen to leave out the internal comments its
reader cannot see, and the edit screen row allows private comments
before it expects the toggle.

Assisted-by: Claude Code:claude-opus-5-5
The quick view opens a native popover and loads its comments over
REST, and the Comments screen's list table builds its own query, so
only a browser shows whether either works.

The spec posts an internal comment, opens its post's quick view from
the posts list, reads the comment in the popover, checks it with axe
and closes it with Escape, then expects the comment on the Comments
screen's internal comments view and not on its All view. The settings
helper moves to a file both specs import, and the Comments screen row
passes the empty status the list table queries its All view under.

Assisted-by: Claude Code:claude-opus-5-5
Internal comments appeared only in the meta box: the posts list had
no column for them, the Comments screen listed none of them, as their
status leaves them out of its All view, while its Trash view showed
trashed ones to every moderator, and the comment edit screen offered
neither their revisions nor their privacy.

An AdminScreens component adds v1's column to the lists of the
selected post types, after the comments column or where v1's filter,
deprecated, then dws_ic/list_table/preceding_column place it; it
counts the comments its reader can see, links them on the Comments
screen for users who may list them, and opens a quick view, a native
popover whose script loads the post's comments over REST. The Comments
screen gains v1's Internal Comments menu entry and type, queries their
status under that view, leaves them out of every view for users who
may not list them, drops the rows its reader cannot see from the query
it marks, and keeps only the edit, trash, restore and delete actions.
The edit screen lists a comment's earlier texts and lets its author
change whether it is private through core's own comment meta field,
which the data guard already limits to the author. Author links in the
comment meta are underlined, as axe found them told apart by colour
alone.

Assisted-by: Claude Code:claude-opus-5-5
v1 offered internal comments on orders only where they were posts;
with WooCommerce's order tables the order screen, its list and its
ids no longer go through the posts table.

The rows run on WooCommerce's four storages, with Linked Orders'
storage helper: an order of a selected type takes comments over the
REST routes from administrators and shop managers but not editors, and
lists them; the order screen, classic or the order tables', shows the
meta box with the order's comments; the orders list shows the column
with the order's count; and the plugin declares itself compatible with
the order tables.

Assisted-by: Claude Code:claude-opus-5-5
The tests environment stores orders in WooCommerce's order tables,
whose edit screen holds an order rather than a post, so the meta box's
script must find its order there.

The meta box spec selects orders beside posts and posts a comment on a
new order's edit screen, deleting the order afterwards.

Assisted-by: Claude Code:claude-opus-5-5
The meta box, the REST routes and the column took every id for a
post's, so an order stored in WooCommerce's order tables, which has no
post or only a placeholder, read as no selected type, and its edit
screen and list, which are not a post's, showed nothing.

dws_ic_get_post_type() names an order's type through OrderUtil where
the posts table holds no order, and the selected-type check and the
empty-list message use it. The REST routes rely on it and on edit_post,
which WooCommerce maps for orders in any storage. The meta box takes
the edited post or order, and finds the order tables screen's order in
the global that screen sets; the column also hooks each selected type's
order tables list, reading the type from the running hook, and loads
the quick view there. The plugin declares itself compatible with the
order tables.

Assisted-by: Claude Code:claude-opus-5-5
…ooks

Code written against v1 calls its root getters and hook-tag builders,
reads its root constants and listens on its boot hooks, which the port
keeps deprecated, as Linked Orders' port does.

The rows, ported from Linked Orders', expect the getters and
constants to answer from the plugin's header, each getter reporting
its deprecation; v1's hook-tag builders to build v1's names, mapping
v1's sixteen component ids to the roots their names gave, and null for
an id v1 had no component for; and v1's boot hooks to fire deprecated
after the v2 ones, the failure hook with the Throwable.

Assisted-by: Claude Code:claude-opus-5-5
Code written against v1 called its root getters and hook-tag builders,
read its root constants and listened on dws_ic_initialized and
dws_ic_initialization_failure, none of which the port defined.

includes/deprecated.php, ported from Linked Orders', defines v1's
constants from the plugin's header and its getters and hook-tag
builders, each reporting its deprecation; the component builder maps
v1's sixteen component ids to the roots their names gave. v1's boot
hooks fire deprecated after dws_ic/boot/succeeded and
dws_ic/boot/failed. The changelog gains the fragments for the v1 API
the port keeps, renames and removes.

Assisted-by: Claude Code:claude-opus-5-5
Internal Comments ships from wordpress.org, as v1 did, but the port
still carried the template's GitHub distribution: an Update URI, its
own text-domain loading, no readme.txt, a changelog holding the
template's placeholder and a release that publishes to GitHub only.

The entry file drops both, which turns the bundled updater inert and
leaves translations to language packs. readme.txt keeps v1's sections
updated for v2: the premium features are features, the dead links are
gone, support points to the forum, and the Upgrade Notice names the
floors, the background move that a return to 1.2.4 does not see, the
post-level access and the removed v1 API. CHANGELOG.md holds v1's
history between the markers that changelog:write copies into the
readme through the Pronamic sync it now runs. The quality workflow
lints the readme, and the release passes wp-org.

Assisted-by: Claude Code:claude-opus-5-5
Both plugins boot on plugins_loaded with the framework scoped under
their own prefixes, and both add a meta box to WooCommerce's order
screen, which the shared environment only checked by activating them.

The spec reads each plugin's boot event over WP-CLI and expects both
meta boxes on one order tables edit screen, selecting orders for
Internal Comments for the row and clearing them afterwards.

Assisted-by: Claude Code:claude-opus-5-5
readme.txt repeats the entry file's version and floors, which nothing
checked, and the functions and model v1 had carried 2.0.0 as their
@SInCE, though the port's convention keeps v1's.

A Unit row, ported from Linked Orders', expects the readme's Stable
tag and floors to match the entry file's and its changelog markers to
stay where the sync fills them. The comment and settings functions and
the DWS_Internal_Comment model take the @SInCE v1 gave them.

Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md, README.md, docs/ARCHITECTURE.md and tests/README.md still
described the template's demo plugin, its WooCommerce settings tab,
blocks and greeting, and the plugin carried the template's recipes and
an empty templates/ folder.

The docs describe the port: its layout, the internal status and its
guards, v1's settings page, capabilities, comment API, meta and
migration, the REST routes, meta box, admin screens and orders, the
suites that cover them, and the limitations, among them a return to
1.2.4 storing new comments approved. The recipes give way to a link to
the template's, as Linked Orders' docs do, and templates/ goes.

Assisted-by: Claude Code:claude-opus-5-5
Claude's review found that v1's approved comments stay public until
the batched step moves them, that WooCommerce rebuilds its orders and
coupons columns after the plugin adds its own, and that a post trashed
under v1 recorded its internal comments as approved.

One row expects a v1 row left out of comment queries and feeds while
the move is pending, and back once the revision passes it; one expects
the column's filter to run after WooCommerce's at the default
priority; one expects a trashed post's comments read from v1's record.

Assisted-by: Claude Code:claude-opus-5-5
Claude's review at high found five issues the port should fix. v1's
approved comments stayed public until the batched step moved them,
which on a site without a working scheduler is indefinitely; WooCommerce
rebuilt its orders' and coupons' columns after the plugin added its
own; the meta box treated only an EMPTY_TRASH_DAYS of 0 as no trash,
while WordPress deletes at once on any falsy value; a post trashed
under v1 recorded its internal comments as approved, which the trash
reader skipped; and the column checked every comment's capabilities
to show a number.

Until the revision reaches DWS_IC_REVISION_V1_COMMENTS_MOVED, Comments
leaves the internal type out of every comment query and feed that
names neither it nor the status. The column's filter runs at priority
20. The meta box reads EMPTY_TRASH_DAYS as WordPress does. The trash
reader takes v1's approved record for the internal status. The column
counts with one query for a user who sees every comment of the post.
The REST routes declare their path parameters and read the content
through an is_string() guard, and the bulk-action filter sanitizes the
view it reads, as AGENTS.md's request reads require.

The review's other five findings are declined in the evidence note;
the trash capability's gap on core's own screens joins AGENTS.md's
limitations.

Assisted-by: Claude Code:claude-opus-5-5
Codex's first round found that the meta box's fields post with the
post's form, whose content field WordPress saves as the post's body;
that core's REST API lets any user who moderates comments update or
delete an internal comment, private ones included; that a deleted
private comment's replies lose the privacy they took from it, as
WordPress hands them to its parent; and that the script returns the
focus to a box that cannot take it.

The rows expect no named field in the meta box and a focusable box,
core's REST writes refused without the plugin's capabilities, and the
replies of a deleted private comment still private.

Assisted-by: Claude Code:claude-opus-5-5
The meta box's fields carried names, so the classic edit screen
submitted them with the post, and WordPress saved the note's textarea,
named content, as the post's body. Core's REST API let any user who
moderates comments update or delete an internal comment, answering
with its text. Deleting a private comment permanently, as emptying the
trash does, handed its replies to its parent and dropped their
privacy. The script returned the focus to a box that could not take it.

The meta box's fields are unnamed, as the script reads them directly,
and the box takes focus. Permissions refuses a core REST write to an
internal comment, checking the plugin's edit capability, or its trash
capability for a deletion, before core's own check runs. Comments
marks the replies of a comment marked private as private before
WordPress deletes it, past the guard that keeps a privacy change to
the comment's author.

Assisted-by: Claude Code:claude-opus-5-5
Codex's second round found that the REST guard read the comment id
from the route while core acts on the request's id, which a query
parameter overrides, and matched the route by letter case while the
REST server does not; and that core lets a comment's author read it,
alone or in a collection, before it asks about the comment's post.

The rows expect a deletion refused whether the private comment's id
rides in the query or the route's letters change case, and an internal
comment refused to its author through core's single and collection
reads once the author may no longer see it.

Assisted-by: Claude Code:claude-opus-5-5
The REST guard read the comment id from the route, while core acts on
the request's id, which a query or body parameter overrides, and
matched the route by letter case, while the REST server ignores case,
so either let a moderator delete a private comment. Core also let a
comment's author read it, alone or in a collection, without the access
to its post that seeing it needs.

Permissions recognizes core's comments controller by the handler the
REST server matched and checks the request's id against the see, edit
or trash capability of its get, update or delete handler. Core's
comment collections leave internal comments out, as only the plugin's
own routes serve them.

Assisted-by: Claude Code:claude-opus-5-5
Codex's third round found that XML-RPC's wp.getComments lets any user
who moderates comments list the comments of any status they name, so
an editor reads internal comments, administrators' private ones
included, without the plugin's checks.

The row, in a process of its own since XML-RPC requests define a
constant, expects wp.getComments to list no internal comment for an
editor naming their status.

Assisted-by: Claude Code:claude-opus-5-5
XML-RPC's wp.getComments lists the comments of any status a user who
moderates comments names, so an editor could read internal comments,
administrators' private ones included, past the plugin's checks.

Permissions leaves internal comments out of every comment query of an
XML-RPC request; XML-RPC's single-comment methods already check
edit_comment, which the plugin maps. The type exclusion that queries,
core's REST collections, the Comments screen and XML-RPC share lives
in dws_ic_exclude_internal_type(), which keeps the empty type out of
the list, since it names the comments older WordPress stored without
one.

Assisted-by: Claude Code:claude-opus-5-5
Codex's fourth round found that core's replyto-comment action takes
the comment's type from the request and checks only access to the
post, so a user without the capability to post internal comments
posts one, which the status guard then keeps.

The row drives the action as a user without the capability, and with
it, refusing an internal type in any letter case and letting a public
reply through; it sets WooCommerce's review handler aside, whose own
nonce check would refuse every request first, and reads the user's
capabilities again after the role changes.

Assisted-by: Claude Code:claude-opus-5-5
Core's replyto-comment action takes the comment's type from the
request and checks only access to the post, so a user with that access
but without insert_dws_internal_comments could post an internal
comment with a hand-built request.

Permissions refuses the action an internal type, read through
sanitize_key() as core trims it, unless the user holds the capability.
It runs at -10, before WooCommerce's review handler at -1 and core's
at 1, and before core's nonce check, which a refusal does not need.

Assisted-by: Claude Code:claude-opus-5-5
Codex's fifth round found that the exclusion keeping internal comments
out of XML-RPC's comment lists applies to every comment query of the
request, so it also hides the replies of a private comment that
wp.deleteComment removes from the query that marks them private,
and WordPress hands them to the comment's parent without it.

The row, in a process of its own since XML-RPC requests define a
constant, deletes a private comment with XMLRPC_REQUEST defined and
expects its reply still private.

Assisted-by: Claude Code:claude-opus-5-5
The exclusion keeping internal comments out of XML-RPC's comment lists
applied to every comment query of the request, so when wp.deleteComment
removed a private comment, the query that marks its replies private
found none, and WordPress handed them to the parent without privacy.

The exclusion skips a query that names the internal type, which
wp.getComments never does, as it takes no type from its caller, and
the plugin's own queries always do. The check lives in
dws_ic_query_names_internal_type(), which the interim exclusion during
the v1 move shares.

Assisted-by: Claude Code:claude-opus-5-5
Codex's sixth round found that the REST route stores a comment marked
private as one every reader sees when the site disallows private
comments or the user lacks the capability, as when the setting changes
while the form is open; and that every change the meta box makes
replaces the list, discarding the text of any edit or reply form open
on another comment.

The REST row expects a private request refused with 403 where the
site disallows privacy, and a public one still stored; the browser row
opens a reply on one comment, trashes another, and expects the reply's
text kept.

Assisted-by: Claude Code:claude-opus-5-5
The REST route stored a comment its author marked private as one every
reader sees when the site disallowed private comments or the user
lacked the capability, as when the setting changed while the meta box
was open. Every change the meta box made also replaced the list, which
discarded the text of edit and reply forms open on other comments.

The route refuses a private request it cannot honor with 403 before
any hook fires, and the meta box keeps the comment's text with the
message. After a change, the forms open on other comments move to the
same comments in the new list with their drafts and listeners.

Assisted-by: Claude Code:claude-opus-5-5
Codex's seventh round found that the meta box disables only the
buttons of the form it sends, so text typed after pressing Post is
never stored and the answer, which clears or replaces the form,
discards it.

The browser row holds the request back, expects the new comment's
field not editable until the server answers, and editable after.

Assisted-by: Claude Code:claude-opus-5-5
The meta box disabled only the buttons of the form it sent, so text
typed after pressing Post was never stored, and the answer, which
clears or replaces the form, discarded it.

A request also disables the form's checkboxes and makes its text
fields read-only, which keeps the focus that disabling would drop,
until the server answers. AGENTS.md's limitations add that the replies
of a private comment deleted permanently become private under their
own authors, who then also see the replies beneath them.

Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md said core's REST API trashes an internal comment for any
user who can edit it, which held until Permissions began checking the
trash capability for core's REST deletions; only the Comments screen
still checks just edit_comment.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Feat/ic port Port Internal Comments to the v2 framework Oct 31, 2026
@ahegyes
ahegyes merged commit c3449cd into trunk Oct 31, 2026
52 checks passed
@ahegyes
ahegyes deleted the feat/ic-port branch October 31, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant