From 39b156d26f5a9e740a8dc8a4905e801502afdec5 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 15:22:24 +0100 Subject: [PATCH 1/6] WIP: notes for image blocks & paragraphs with links --- .../providers/class-content-review.php | 494 +++++++++++++++++- 1 file changed, 492 insertions(+), 2 deletions(-) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index f0898f5865..bd16f188c9 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -16,6 +16,13 @@ class Content_Review extends Tasks { use Dismissable_Task; + /** + * The note prefix used to identify Progress Planner notes. + * + * @var string + */ + public const NOTE_PREFIX = '[PRPL]'; + /** * The capability required to perform the task. * @@ -116,6 +123,33 @@ public function init() { } $this->init_dismissable_task(); + + // Handle note injection. + if ( $this->supports_notes() ) { + \add_action( 'load-post.php', [ $this, 'maybe_inject_notes' ] ); + } + } + + /** + * Maybe inject notes when editing a post. + * + * @return void + */ + public function maybe_inject_notes() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No action taken, just injecting notes. + if ( ! isset( $_GET['prpl_inject_notes'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0; + if ( ! $post_id || ! \current_user_can( 'edit_post', $post_id ) ) { + return; + } + + // Inject all note types. + $this->inject_image_review_notes( $post_id ); + $this->inject_link_review_notes( $post_id ); } /** @@ -498,8 +532,31 @@ protected function is_specific_task_completed( $task_id ) { $data = $task->get_data(); - return $data && isset( $data['target_post_id'] ) - && (int) \get_post_modified_time( 'U', false, (int) $data['target_post_id'] ) > \strtotime( '-12 months' ); + if ( ! $data || ! isset( $data['target_post_id'] ) ) { + return false; + } + + $target_post_id = (int) $data['target_post_id']; + + // Check if notes-based completion is applicable (WP 6.9+ and notes exist). + if ( $this->supports_notes() ) { + $notes_resolved = $this->all_notes_resolved( $target_post_id ); + + // If notes exist and all are resolved, task is complete. + if ( true === $notes_resolved ) { + return true; + } + + // If notes exist but some are open, task is not complete. + if ( false === $notes_resolved ) { + return false; + } + + // If no notes exist (null), fall back to modification time check. + } + + // Original completion check: post was modified within the last 12 months. + return (int) \get_post_modified_time( 'U', false, $target_post_id ) > \strtotime( '-12 months' ); } /** @@ -576,8 +633,441 @@ public function add_task_actions( $data = [], $actions = [] ) { 'priority' => 10, 'html' => '' . \esc_html__( 'Review', 'progress-planner' ) . '', ]; + + // Add "Review with Notes" action for WordPress 6.9+. + if ( $this->supports_notes() ) { + $actions[] = [ + 'priority' => 11, + 'html' => '' . \esc_html__( 'Review with Notes', 'progress-planner' ) . '', + ]; + } } return $actions; } + + /** + * Check if WordPress supports the Notes feature (6.9+). + * + * @return bool + */ + public function supports_notes() { + global $wp_version; + return \version_compare( $wp_version, '6.9', '>=' ); + } + + /** + * Create a note on a specific post. + * + * @param int $post_id The post ID to attach the note to. + * @param string $content The note content. + * @param int $parent_id Parent note ID for threading (0 for top-level). + * + * @return int|false The note (comment) ID or false on failure. + */ + public function create_note( $post_id, $content, $parent_id = 0 ) { + $note_data = [ + 'comment_post_ID' => $post_id, + 'comment_content' => $content, + 'comment_type' => 'note', + 'comment_approved' => 0, // 0 = open, 1 = resolved. + 'comment_parent' => $parent_id, + 'user_id' => \get_current_user_id(), + ]; + + // Bypass filters that might interfere with note creation. + \add_filter( 'duplicate_comment_id', '__return_false' ); + \add_filter( 'comment_flood_filter', '__return_false' ); + + $note_id = \wp_insert_comment( $note_data ); + + \remove_filter( 'duplicate_comment_id', '__return_false' ); + \remove_filter( 'comment_flood_filter', '__return_false' ); + + return $note_id; + } + + /** + * Get all Progress Planner notes for a post. + * + * @param int $post_id The post ID. + * @param string $status 'open' for unresolved, 'resolved' for resolved, 'all' for both. + * + * @return array Array of note comments. + */ + public function get_notes( $post_id, $status = 'all' ) { + $args = [ + 'post_id' => $post_id, + 'type' => 'note', + 'search' => static::NOTE_PREFIX, // Only get our notes. + ]; + + if ( 'open' === $status ) { + $args['status'] = 'hold'; // WordPress maps 0 to 'hold'. + } elseif ( 'resolved' === $status ) { + $args['status'] = 'approve'; // WordPress maps 1 to 'approve'. + } + + return \get_comments( $args ); + } + + /** + * Check if all Progress Planner notes are resolved for a post. + * + * @param int $post_id The post ID. + * + * @return bool|null True if all resolved, false if open notes exist, null if no notes. + */ + public function all_notes_resolved( $post_id ) { + $all_notes = $this->get_notes( $post_id, 'all' ); + + if ( empty( $all_notes ) ) { + return null; // No notes created yet. + } + + $open_notes = $this->get_notes( $post_id, 'open' ); + + return empty( $open_notes ); + } + + /** + * Find image blocks in a post. + * + * @param int $post_id The post ID. + * + * @return array Array of image block data. + */ + public function find_image_blocks( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $image_blocks = []; + + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + $image_blocks[] = [ + 'index' => $index, + 'block' => $block, + 'alt' => $block['attrs']['alt'] ?? '', + 'id' => $block['attrs']['id'] ?? 0, + ]; + } + + // Also check inner blocks (for groups, columns, etc.). + if ( ! empty( $block['innerBlocks'] ) ) { + $inner_images = $this->find_image_blocks_recursive( $block['innerBlocks'], $index ); + $image_blocks = \array_merge( $image_blocks, $inner_images ); + } + } + + return $image_blocks; + } + + /** + * Recursively find image blocks in inner blocks. + * + * @param array $blocks The blocks to search. + * @param string $parent_index The parent block index. + * + * @return array Array of image block data. + */ + protected function find_image_blocks_recursive( $blocks, $parent_index ) { + $image_blocks = []; + + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + $image_blocks[] = [ + 'index' => "{$parent_index}.{$index}", + 'block' => $block, + 'alt' => $block['attrs']['alt'] ?? '', + 'id' => $block['attrs']['id'] ?? 0, + ]; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $inner = $this->find_image_blocks_recursive( $block['innerBlocks'], "{$parent_index}.{$index}" ); + $image_blocks = \array_merge( $image_blocks, $inner ); + } + } + + return $image_blocks; + } + + /** + * Inject review notes for images on a post. + * + * @param int $post_id The post ID to inject notes on. + * + * @return array Array of created note IDs. + */ + public function inject_image_review_notes( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $created_notes = []; + $image_num = 0; + $modified = false; + + $blocks = $this->process_blocks_for_notes( $blocks, $post_id, $created_notes, $image_num, $modified ); + + // Update post content with block metadata if we created notes. + if ( $modified && ! empty( $created_notes ) ) { + $new_content = \serialize_blocks( $blocks ); + + \wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => $new_content, + ] + ); + } + + return $created_notes; + } + + /** + * Process blocks recursively to add notes to image blocks. + * + * @param array $blocks The blocks to process. + * @param int $post_id The post ID. + * @param array $created_notes Array to collect created note IDs (passed by reference). + * @param int $image_num Image counter (passed by reference). + * @param bool $modified Whether any blocks were modified (passed by reference). + * + * @return array The processed blocks. + */ + protected function process_blocks_for_notes( $blocks, $post_id, &$created_notes, &$image_num, &$modified ) { + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + ++$image_num; + + // Skip if block already has a valid note (exists in database). + $existing_note_id = $block['attrs']['metadata']['noteId'] ?? 0; + if ( $existing_note_id && \get_comment( $existing_note_id ) ) { + continue; + } + + $alt_text = $block['attrs']['alt'] ?? ''; + $alt_text_str = $alt_text ? " (alt: \"{$alt_text}\")" : ' (no alt text)'; + $is_linked = $this->image_block_has_link( $block ); + + if ( $is_linked ) { + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Image number, %3$s: Alt text info. */ + \__( '%1$s Review Image %2$d%3$s: This image is linked. Check that both the image and link are still relevant and working.', 'progress-planner' ), + static::NOTE_PREFIX, + $image_num, + $alt_text_str + ); + } else { + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Image number, %3$s: Alt text info. */ + \__( '%1$s Review Image %2$d%3$s: Is this image still relevant and displaying correctly?', 'progress-planner' ), + static::NOTE_PREFIX, + $image_num, + $alt_text_str + ); + } + + $note_id = $this->create_note( $post_id, $note_content ); + + if ( $note_id ) { + $created_notes[] = $note_id; + + // Add note ID to block metadata. + if ( ! isset( $blocks[ $index ]['attrs']['metadata'] ) ) { + $blocks[ $index ]['attrs']['metadata'] = []; + } + $blocks[ $index ]['attrs']['metadata']['noteId'] = $note_id; + + $modified = true; + } + } + + // Process inner blocks recursively. + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks[ $index ]['innerBlocks'] = $this->process_blocks_for_notes( + $block['innerBlocks'], + $post_id, + $created_notes, + $image_num, + $modified + ); + } + } + + return $blocks; + } + + /** + * Inject review notes for paragraphs with links on a post. + * + * @param int $post_id The post ID to inject notes on. + * + * @return array Array of created note IDs. + */ + public function inject_link_review_notes( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $created_notes = []; + $paragraph_num = 0; + $modified = false; + + $blocks = $this->process_blocks_for_link_notes( $blocks, $post_id, $created_notes, $paragraph_num, $modified ); + + // Update post content with block metadata if we created notes. + if ( $modified && ! empty( $created_notes ) ) { + $new_content = \serialize_blocks( $blocks ); + + \wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => $new_content, + ] + ); + } + + return $created_notes; + } + + /** + * Process blocks recursively to add notes to paragraphs with links. + * + * @param array $blocks The blocks to process. + * @param int $post_id The post ID. + * @param array $created_notes Array to collect created note IDs (passed by reference). + * @param int $paragraph_num Paragraph counter (passed by reference). + * @param bool $modified Whether any blocks were modified (passed by reference). + * + * @return array The processed blocks. + */ + protected function process_blocks_for_link_notes( $blocks, $post_id, &$created_notes, &$paragraph_num, &$modified ) { + foreach ( $blocks as $index => $block ) { + // Check if this is a paragraph block with links. + if ( 'core/paragraph' === $block['blockName'] && $this->block_has_links( $block ) ) { + ++$paragraph_num; + + // Skip if block already has a valid note (exists in database). + $existing_note_id = $block['attrs']['metadata']['noteId'] ?? 0; + if ( $existing_note_id && \get_comment( $existing_note_id ) ) { + continue; + } + + $link_count = $this->count_links_in_block( $block ); + + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Paragraph number, %3$d: Number of links. */ + \_n( + '%1$s Review Paragraph %2$d: Check the %3$d link - is it still working and relevant?', + '%1$s Review Paragraph %2$d: Check the %3$d links - are they still working and relevant?', + $link_count, + 'progress-planner' + ), + static::NOTE_PREFIX, + $paragraph_num, + $link_count + ); + + $note_id = $this->create_note( $post_id, $note_content ); + + if ( $note_id ) { + $created_notes[] = $note_id; + + // Add note ID to block metadata. + if ( ! isset( $blocks[ $index ]['attrs']['metadata'] ) ) { + $blocks[ $index ]['attrs']['metadata'] = []; + } + $blocks[ $index ]['attrs']['metadata']['noteId'] = $note_id; + + $modified = true; + } + } + + // Process inner blocks recursively. + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks[ $index ]['innerBlocks'] = $this->process_blocks_for_link_notes( + $block['innerBlocks'], + $post_id, + $created_notes, + $paragraph_num, + $modified + ); + } + } + + return $blocks; + } + + /** + * Check if a block contains links. + * + * @param array $block The block to check. + * + * @return bool True if block contains links. + */ + protected function block_has_links( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + return false !== \strpos( $inner_html, '' ); + } + + /** + * Check if an image block is wrapped in a link. + * + * @param array $block The image block to check. + * + * @return bool True if image is linked. + */ + protected function image_block_has_link( $block ) { + // Check block attributes for link destination. + if ( ! empty( $block['attrs']['linkDestination'] ) && 'none' !== $block['attrs']['linkDestination'] ) { + return true; + } + + // Also check innerHTML for anchor tags. + $inner_html = $block['innerHTML'] ?? ''; + return false !== \strpos( $inner_html, 'get_notes( $post_id, 'all' ); + $deleted = 0; + + foreach ( $notes as $note ) { + if ( $note instanceof \WP_Comment && \wp_delete_comment( $note->comment_ID, $force_delete ) ) { + ++$deleted; + } + } + + return $deleted; + } } From a627c7214d4a7102da7bf6f3beb7e4e45bf4c2ca Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 16:04:11 +0100 Subject: [PATCH 2/6] add avatars and apply styles --- .../providers/class-content-review.php | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index bd16f188c9..b0f8da3f91 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -124,9 +124,11 @@ public function init() { $this->init_dismissable_task(); - // Handle note injection. + // Handle note injection and avatar customization. if ( $this->supports_notes() ) { \add_action( 'load-post.php', [ $this, 'maybe_inject_notes' ] ); + \add_filter( 'pre_get_avatar_data', [ $this, 'filter_note_avatar' ], 10, 2 ); + \add_action( 'admin_head', [ $this, 'add_note_avatar_styles' ] ); } } @@ -656,6 +658,64 @@ public function supports_notes() { return \version_compare( $wp_version, '6.9', '>=' ); } + /** + * Filter avatar data for PRPL notes to show Progress Planner logo. + * + * @param array $args Arguments passed to get_avatar_data(). + * @param mixed $id_or_email User ID, email, WP_User, WP_Post, or WP_Comment. + * + * @return array Modified arguments. + */ + public function filter_note_avatar( $args, $id_or_email ) { + // Only process WP_Comment objects. + if ( ! $id_or_email instanceof \WP_Comment ) { + return $args; + } + + // Check if this is a note with our prefix. + if ( 'note' !== $id_or_email->comment_type ) { + return $args; + } + + if ( false === \strpos( $id_or_email->comment_content, static::NOTE_PREFIX ) ) { + return $args; + } + + // Use Progress Planner logo as avatar. + $args['url'] = \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/images/icon_progress_planner.svg'; + $args['found_avatar'] = true; + + return $args; + } + + /** + * Add CSS styles for PRPL note avatars in the editor. + * + * @return void + */ + public function add_note_avatar_styles() { + $screen = \get_current_screen(); + if ( ! $screen || 'post' !== $screen->base ) { + return; + } + + $logo_url = \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/images/icon_progress_planner.svg'; + ?> + + Date: Wed, 24 Dec 2025 16:58:59 +0100 Subject: [PATCH 3/6] php unit test --- tests/phpunit/test-class-content-review.php | 338 ++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/phpunit/test-class-content-review.php diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php new file mode 100644 index 0000000000..4192b705e6 --- /dev/null +++ b/tests/phpunit/test-class-content-review.php @@ -0,0 +1,338 @@ +content_review = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( 'review-post' ); + + // Create a test post with blocks. + $this->test_post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post with Images', + 'post_content' => '
Test image

This is a test link in a paragraph.

', + 'post_status' => 'publish', + ] + ); + } + + /** + * Tear down test. + * + * @return void + */ + public function tearDown(): void { + // Delete test post and its notes. + if ( $this->test_post_id ) { + $this->content_review->delete_notes( $this->test_post_id ); + \wp_delete_post( $this->test_post_id, true ); + } + + \wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test supports_notes method. + * + * @return void + */ + public function test_supports_notes() { + global $wp_version; + + $result = $this->content_review->supports_notes(); + + // Should return true for WP 6.9+ and false for earlier versions. + $expected = \version_compare( $wp_version, '6.9', '>=' ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test create_note method. + * + * @return void + */ + public function test_create_note() { + $note_content = Content_Review::NOTE_PREFIX . ' Test note content'; + $note_id = $this->content_review->create_note( $this->test_post_id, $note_content ); + + $this->assertIsInt( $note_id ); + $this->assertGreaterThan( 0, $note_id ); + + // Verify the note was created correctly. + $note = \get_comment( $note_id ); + $this->assertInstanceOf( \WP_Comment::class, $note ); + $this->assertEquals( 'note', $note->comment_type ); + $this->assertEquals( $note_content, $note->comment_content ); + $this->assertEquals( '0', $note->comment_approved ); // 0 = open. + } + + /** + * Test get_notes method returns all notes. + * + * @return void + */ + public function test_get_notes_all() { + // Create two notes. + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 1' ); + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 2' ); + + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + + $this->assertCount( 2, $notes ); + } + + /** + * Test get_notes method filters by status. + * + * @return void + */ + public function test_get_notes_by_status() { + // Create an open note. + $open_note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Open note' ); + + // Create a resolved note. + $resolved_note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Resolved note' ); + \wp_update_comment( + [ + 'comment_ID' => $resolved_note_id, + 'comment_approved' => 1, // 1 = resolved. + ] + ); + + $open_notes = $this->content_review->get_notes( $this->test_post_id, 'open' ); + $this->assertCount( 1, $open_notes ); + + $resolved_notes = $this->content_review->get_notes( $this->test_post_id, 'resolved' ); + $this->assertCount( 1, $resolved_notes ); + } + + /** + * Test all_notes_resolved returns null when no notes exist. + * + * @return void + */ + public function test_all_notes_resolved_no_notes() { + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertNull( $result ); + } + + /** + * Test all_notes_resolved returns false when open notes exist. + * + * @return void + */ + public function test_all_notes_resolved_with_open_notes() { + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Open note' ); + + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertFalse( $result ); + } + + /** + * Test all_notes_resolved returns true when all notes are resolved. + * + * @return void + */ + public function test_all_notes_resolved_all_resolved() { + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note' ); + \wp_update_comment( + [ + 'comment_ID' => $note_id, + 'comment_approved' => 1, + ] + ); + + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertTrue( $result ); + } + + /** + * Test find_image_blocks method. + * + * @return void + */ + public function test_find_image_blocks() { + $image_blocks = $this->content_review->find_image_blocks( $this->test_post_id ); + + $this->assertCount( 1, $image_blocks ); + $this->assertEquals( 'core/image', $image_blocks[0]['block']['blockName'] ); + } + + /** + * Test find_image_blocks with nested blocks. + * + * @return void + */ + public function test_find_image_blocks_nested() { + $nested_post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post with Nested Image', + 'post_content' => '
Nested image
', + 'post_status' => 'publish', + ] + ); + + $image_blocks = $this->content_review->find_image_blocks( $nested_post_id ); + + $this->assertCount( 1, $image_blocks ); + $this->assertStringContainsString( '.', $image_blocks[0]['index'] ); // Nested index like "0.0". + + \wp_delete_post( $nested_post_id, true ); + } + + /** + * Test delete_notes method. + * + * @return void + */ + public function test_delete_notes() { + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 1' ); + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 2' ); + + $deleted = $this->content_review->delete_notes( $this->test_post_id ); + + $this->assertEquals( 2, $deleted ); + + $remaining_notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 0, $remaining_notes ); + } + + /** + * Test filter_note_avatar for non-PRPL notes. + * + * @return void + */ + public function test_filter_note_avatar_non_prpl_note() { + // Create a regular comment (not a PRPL note). + $comment_id = $this->factory->comment->create( + [ + 'comment_post_ID' => $this->test_post_id, + 'comment_content' => 'Regular comment', + 'comment_type' => 'comment', + ] + ); + + $comment = \get_comment( $comment_id ); + $args = [ 'url' => 'https://gravatar.com/test' ]; + $result_args = $this->content_review->filter_note_avatar( $args, $comment ); + + // Should not modify args for regular comments. + $this->assertEquals( 'https://gravatar.com/test', $result_args['url'] ); + } + + /** + * Test filter_note_avatar for PRPL notes. + * + * @return void + */ + public function test_filter_note_avatar_prpl_note() { + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); + $note = \get_comment( $note_id ); + + $args = [ 'url' => 'https://gravatar.com/test' ]; + $result_args = $this->content_review->filter_note_avatar( $args, $note ); + + // Should replace avatar URL with Progress Planner logo. + $this->assertStringContainsString( 'icon_progress_planner.svg', $result_args['url'] ); + $this->assertTrue( $result_args['found_avatar'] ); + } + + /** + * Test inject_image_review_notes method. + * + * @return void + */ + public function test_inject_image_review_notes() { + $created_notes = $this->content_review->inject_image_review_notes( $this->test_post_id ); + + $this->assertCount( 1, $created_notes ); + + // Verify the note was created. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + $this->assertStringContainsString( 'Review Image', $notes[0]->comment_content ); + } + + /** + * Test inject_image_review_notes does not create duplicate notes. + * + * @return void + */ + public function test_inject_image_review_notes_no_duplicates() { + // Inject notes twice. + $this->content_review->inject_image_review_notes( $this->test_post_id ); + $created_notes = $this->content_review->inject_image_review_notes( $this->test_post_id ); + + // Second call should not create new notes. + $this->assertCount( 0, $created_notes ); + + // Should still have only 1 note total. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + } + + /** + * Test inject_link_review_notes method. + * + * @return void + */ + public function test_inject_link_review_notes() { + $created_notes = $this->content_review->inject_link_review_notes( $this->test_post_id ); + + $this->assertCount( 1, $created_notes ); + + // Verify the note was created. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + $this->assertStringContainsString( 'Review Paragraph', $notes[0]->comment_content ); + } + + /** + * Test NOTE_PREFIX constant. + * + * @return void + */ + public function test_note_prefix_constant() { + $this->assertEquals( '[PRPL]', Content_Review::NOTE_PREFIX ); + } +} From 923316d386df824458bd59a35361770050954aa6 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 17:02:53 +0100 Subject: [PATCH 4/6] remove notes when task is completed --- .../providers/class-content-review.php | 2 + tests/phpunit/test-class-content-review.php | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index b0f8da3f91..caf51c5a1b 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -545,7 +545,9 @@ protected function is_specific_task_completed( $task_id ) { $notes_resolved = $this->all_notes_resolved( $target_post_id ); // If notes exist and all are resolved, task is complete. + // Clean up the notes so new ones can be created in the next review cycle. if ( true === $notes_resolved ) { + $this->delete_notes( $target_post_id ); return true; } diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php index 4192b705e6..47fac7f4e3 100644 --- a/tests/phpunit/test-class-content-review.php +++ b/tests/phpunit/test-class-content-review.php @@ -335,4 +335,45 @@ public function test_inject_link_review_notes() { public function test_note_prefix_constant() { $this->assertEquals( '[PRPL]', Content_Review::NOTE_PREFIX ); } + + /** + * Test that notes are cleaned up when task is completed. + * + * @return void + */ + public function test_notes_cleaned_up_on_task_completion() { + // Create and resolve a note. + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); + \wp_update_comment( + [ + 'comment_ID' => $note_id, + 'comment_approved' => 1, // Resolved. + ] + ); + + // Verify note exists before completion check. + $notes_before = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes_before ); + + // Create a task in the database so is_specific_task_completed can find it. + $task_id = 'review-post-' . $this->test_post_id; + $task_data = [ + 'task_id' => $task_id, + 'provider_id' => 'review-post', + 'target_post_id' => $this->test_post_id, + ]; + \progress_planner()->get_suggested_tasks_db()->add( $task_data ); + + // Call the method that checks completion (this should trigger cleanup). + $reflection = new \ReflectionClass( $this->content_review ); + $method = $reflection->getMethod( 'is_specific_task_completed' ); + $method->setAccessible( true ); + $is_completed = $method->invoke( $this->content_review, $task_id ); + + $this->assertTrue( $is_completed ); + + // Notes should be cleaned up after task completion. + $notes_after = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 0, $notes_after ); + } } From 97f8361f32547ebd135119131831b5e6e48ffa00 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 17:08:55 +0100 Subject: [PATCH 5/6] update php unit test --- tests/phpunit/test-class-content-review.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php index 47fac7f4e3..dc239bb6d1 100644 --- a/tests/phpunit/test-class-content-review.php +++ b/tests/phpunit/test-class-content-review.php @@ -342,6 +342,11 @@ public function test_note_prefix_constant() { * @return void */ public function test_notes_cleaned_up_on_task_completion() { + // Skip test if notes are not supported (WP < 6.9). + if ( ! $this->content_review->supports_notes() ) { + $this->markTestSkipped( 'Notes feature requires WordPress 6.9+' ); + } + // Create and resolve a note. $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); \wp_update_comment( @@ -361,6 +366,7 @@ public function test_notes_cleaned_up_on_task_completion() { 'task_id' => $task_id, 'provider_id' => 'review-post', 'target_post_id' => $this->test_post_id, + 'post_title' => 'Review Test Post', ]; \progress_planner()->get_suggested_tasks_db()->add( $task_data ); From 3a5bbe3d322c9150fc4f91d673377643e5572a14 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Tue, 25 Aug 2026 16:30:23 +0200 Subject: [PATCH 6/6] Auto-open the Notes sidebar when reviewing a post with notes When a post is opened via the "Review with Notes" action, enqueue a small editor script that opens the "All notes" sidebar once the editor is ready, so the injected review notes are immediately visible. Only enqueued when the post has open PRPL notes. Co-Authored-By: Claude Fable 5 --- assets/js/review-post-notes.js | 34 +++++++++++++++++++ .../providers/class-content-review.php | 27 +++++++++++++++ tests/phpunit/test-class-content-review.php | 27 +++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 assets/js/review-post-notes.js diff --git a/assets/js/review-post-notes.js b/assets/js/review-post-notes.js new file mode 100644 index 0000000000..c294ef3fae --- /dev/null +++ b/assets/js/review-post-notes.js @@ -0,0 +1,34 @@ +/** + * review-post-notes script. + * + * Auto-opens the Notes sidebar in the block editor when a post is opened + * via the "Review with Notes" task action, so the injected review notes + * are immediately visible. + * + * Dependencies: wp-data, wp-dom-ready, wp-editor + */ + +wp.domReady( () => { + // The "All notes" sidebar registered by WordPress 6.9+. + const NOTES_SIDEBAR = 'edit-post/collab-history-sidebar'; + + const openNotesSidebar = () => + wp.data + .dispatch( 'core/interface' ) + .enableComplementaryArea( 'core', NOTES_SIDEBAR ); + + // Wait for the editor to finish initializing, otherwise the sidebar + // restored from user preferences would override ours. + if ( wp.data.select( 'core/editor' ).__unstableIsEditorReady() ) { + openNotesSidebar(); + return; + } + + const unsubscribe = wp.data.subscribe( () => { + if ( ! wp.data.select( 'core/editor' ).__unstableIsEditorReady() ) { + return; + } + unsubscribe(); + openNotesSidebar(); + } ); +} ); diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index 308363187f..ee9a032e5c 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -129,6 +129,7 @@ public function init() { \add_action( 'load-post.php', [ $this, 'maybe_inject_notes' ] ); \add_filter( 'pre_get_avatar_data', [ $this, 'filter_note_avatar' ], 10, 2 ); \add_action( 'admin_head', [ $this, 'add_note_avatar_styles' ] ); + \add_action( 'enqueue_block_editor_assets', [ $this, 'maybe_enqueue_notes_sidebar_script' ] ); } } @@ -154,6 +155,32 @@ public function maybe_inject_notes() { $this->inject_link_review_notes( $post_id ); } + /** + * Enqueue the script that auto-opens the Notes sidebar, + * when a post is opened via the "Review with Notes" action. + * + * @return void + */ + public function maybe_enqueue_notes_sidebar_script() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No action taken, just opening the notes sidebar. + if ( ! isset( $_GET['prpl_inject_notes'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0; + if ( ! $post_id || ! \current_user_can( 'edit_post', $post_id ) ) { + return; + } + + // Bail if there are no open notes to show. + if ( empty( $this->get_notes( $post_id, 'open' ) ) ) { + return; + } + + \progress_planner()->get_admin__enqueue()->enqueue_script( 'review-post-notes' ); + } + /** * Get the task title. * diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php index dc239bb6d1..05874cd002 100644 --- a/tests/phpunit/test-class-content-review.php +++ b/tests/phpunit/test-class-content-review.php @@ -382,4 +382,31 @@ public function test_notes_cleaned_up_on_task_completion() { $notes_after = $this->content_review->get_notes( $this->test_post_id, 'all' ); $this->assertCount( 0, $notes_after ); } + + /** + * Test that the notes-sidebar script is only enqueued when reviewing with open notes. + * + * @return void + */ + public function test_maybe_enqueue_notes_sidebar_script() { + $handle = 'progress-planner/review-post-notes'; + + // Without the URL parameter, nothing is enqueued. + $this->content_review->maybe_enqueue_notes_sidebar_script(); + $this->assertFalse( \wp_script_is( $handle, 'enqueued' ) ); + + $_GET['prpl_inject_notes'] = '1'; + $_GET['post'] = (string) $this->test_post_id; + + // With the URL parameter but no open notes, nothing is enqueued. + $this->content_review->maybe_enqueue_notes_sidebar_script(); + $this->assertFalse( \wp_script_is( $handle, 'enqueued' ) ); + + // With the URL parameter and an open note, the script is enqueued. + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); + $this->content_review->maybe_enqueue_notes_sidebar_script(); + $this->assertTrue( \wp_script_is( $handle, 'enqueued' ) ); + + unset( $_GET['prpl_inject_notes'], $_GET['post'] ); + } }